1#![warn(clippy::pedantic)]
2#![doc = include_str!("../README.md")]
3
4#[cfg(not(target_os = "linux"))]
5compile_error!(
6 "resource-tracker only supports Linux; /proc and cgroup interfaces are Linux-specific."
7);
8
9mod collector;
10mod config;
11mod metrics;
12mod output;
13mod sentinel;
14mod thread_util;
15
16extern crate libc;
17
18use collector::{
19 CpuCollector, DiskCollector, GpuCollector, MemoryCollector, NetworkCollector,
20 collect_host_info, spawn_cloud_discovery,
21};
22use config::{Config, OutputFormat};
23use metrics::CloudInfo;
24use metrics::Sample;
25use rune_redact;
26use sentinel::{BatchUploader, RunContext, SentinelClient, close_run, samples_to_csv, start_run};
27use std::fs::File;
28use std::io::{BufWriter, Write};
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::sync::{Arc, Mutex};
31use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
32
33static SIGTERM_RECEIVED: AtomicBool = AtomicBool::new(false);
38
39extern "C" fn handle_sigterm(_: libc::c_int) {
40 SIGTERM_RECEIVED.store(true, Ordering::Relaxed);
41}
42
43fn setup_signal_handlers() {
47 unsafe {
48 libc::signal(
49 libc::SIGTERM,
50 handle_sigterm as *const () as libc::sighandler_t,
51 );
52 libc::signal(
53 libc::SIGINT,
54 handle_sigterm as *const () as libc::sighandler_t,
55 );
56 }
57}
58
59struct ResourceTracker {
60 config: Config,
61 out_file: Option<std::io::BufWriter<std::fs::File>>,
62 interval: Duration,
63
64 cpu: CpuCollector,
66 memory: MemoryCollector,
67 network: NetworkCollector,
68 disk: DiskCollector,
69 gpu: GpuCollector,
70
71 host_info: metrics::HostInfo,
73 cloud_info: Option<CloudInfo>,
74 cloud_rx: Option<std::sync::mpsc::Receiver<CloudInfo>>,
75
76 child: Option<std::process::Child>,
78
79 sentinel: Option<SentinelClient>,
81 run_ctx_arc: Option<Arc<Mutex<RunContext>>>,
82 sample_buffer: Option<Arc<Mutex<Vec<Sample>>>>,
83 upload_shutdown_flag: Option<Arc<AtomicBool>>,
84 upload_handle: Option<std::thread::JoinHandle<Vec<String>>>,
85
86 unflushed: Vec<Sample>,
88 prev_loop_start: Option<Instant>,
89}
90
91impl ResourceTracker {
92 fn new() -> Self {
93 let config = Config::load();
94 let out_file = Self::create_sink(&config);
95 let interval = Duration::from_secs(config.interval_secs);
96
97 let aggregate_cpu_steal = config.aggregate_cpu_steal || config.format == OutputFormat::Csv;
99
100 let cpu = CpuCollector::new(config.pid, aggregate_cpu_steal);
101 let memory = MemoryCollector::new();
102 let network = NetworkCollector::new();
103 let disk = DiskCollector::new(interval);
104 let gpu = GpuCollector::new();
105
106 let initial_gpus = gpu.collect().unwrap_or_default();
108
109 let host_info = collect_host_info(&initial_gpus);
111
112 let cloud_rx = spawn_cloud_discovery();
114 let cloud_info = None;
115
116 Self {
117 config,
118 out_file,
119 interval,
120 cpu,
121 memory,
122 network,
123 disk,
124 gpu,
125 host_info,
126 cloud_info,
127 cloud_rx,
128 child: None,
129 sentinel: None,
130 run_ctx_arc: None,
131 sample_buffer: None,
132 upload_shutdown_flag: None,
133 upload_handle: None,
134 unflushed: Vec::new(),
135 prev_loop_start: None,
136 }
137 }
138
139 fn warmup_collectors(&mut self) {
140 let _ = self.cpu.collect();
141 let _ = self.network.collect();
142 let _ = self.disk.collect();
143 }
144
145 fn spawn_tracked_command(&mut self) {
146 let Some((program, args)) = self.config.command.split_first() else {
147 return;
148 };
149
150 match std::process::Command::new(program).args(args).spawn() {
151 Ok(c) => {
152 self.config.pid = Some(i32::try_from(c.id()).unwrap_or(i32::MAX));
153 self.cpu.set_tracked_pid(self.config.pid);
154 self.child = Some(c);
155 }
156
157 Err(e) => {
158 eprintln!("error: failed to spawn {:?}: {e}", program);
159 std::process::exit(1);
160 }
161 }
162 }
163
164 fn mask_sensitive_data_in_command(&mut self) {
165 for item in &mut self.config.metadata.command {
166 if let Some(redacted) = Self::try_redact(item) {
167 *item = redacted;
168 }
169 }
170 }
171
172 fn try_redact(raw: &str) -> Option<String> {
173 if raw.starts_with("-----BEGIN") {
175 return Some("[KEY]".to_string());
176 }
177
178 if raw.starts_with("ftp://") {
180 return Self::mask_first_word(raw, "[URL]").into();
181 }
182
183 if raw.starts_with("http://") || raw.starts_with("https://") {
185 if !raw.contains(".") || raw.contains("?") || raw.contains("&") || raw.contains("=") {
186 return Self::mask_first_word(raw, "[URL]");
187 }
188 }
189
190 let redacted = rune_redact::redact(raw);
191 (redacted != raw).then_some(redacted)
192 }
193
194 fn mask_first_word(raw: &str, mask: &str) -> Option<String> {
195 let end_pos = raw.find(' ').unwrap_or(raw.len());
196 Some(format!("{}{}", mask, &raw[end_pos..]).to_owned())
197 }
198
199 fn setup_sentinel(&mut self) {
200 self.sentinel = SentinelClient::from_env();
201
202 let Some(client) = &self.sentinel else {
203 return;
204 };
205
206 if self.cloud_info.is_none() {
208 if let Some(ref rx) = self.cloud_rx {
209 self.cloud_info = rx.recv_timeout(Duration::from_secs(3)).ok();
210 }
211 }
212
213 let default_cloud = CloudInfo::default();
214 let ctx = match start_run(
215 &client.agent,
216 &client.api_base,
217 &client.token,
218 &self.config.metadata,
219 self.config.pid,
220 &self.host_info,
221 self.cloud_info.as_ref().unwrap_or(&default_cloud),
222 ) {
223 Err(e) => {
224 eprintln!("warn: sentinel start_run failed: {e}; streaming disabled");
225 return;
226 }
227 Ok(ctx) => ctx,
228 };
229
230 let ctx_arc = Arc::new(Mutex::new(ctx));
231 let upload_interval = std::env::var("TRACKER_UPLOAD_INTERVAL")
232 .ok()
233 .and_then(|v| v.parse().ok())
234 .unwrap_or(60u64);
235 let (uploader, buf) = BatchUploader::new(upload_interval, self.config.interval_secs);
236 let flag = uploader.shutdown_flag();
237 let upload_handle = uploader.spawn(
238 Arc::clone(&ctx_arc),
239 SentinelClient::new_upload_agent(),
240 client.api_base.clone(),
241 client.token.clone(),
242 );
243 if upload_handle.is_none() {
244 eprintln!(
245 "warn: sentinel background upload disabled; samples will be flushed inline on exit"
246 );
247 }
248
249 self.run_ctx_arc = Some(ctx_arc);
250 self.sample_buffer = Some(buf);
251 self.upload_shutdown_flag = Some(flag);
252 self.upload_handle = upload_handle;
253 }
254
255 fn emit_csv_header(&mut self) {
256 if self.config.format == OutputFormat::Csv {
257 Self::emit_metric_line(&self.config, &mut self.out_file, output::csv::csv_header());
258 }
259 }
260
261 fn renice_tracker(&self) {
262 let Some(renice) = self.config.renice else {
263 return;
264 };
265
266 let result = unsafe { libc::setpriority(libc::PRIO_PROCESS, 0, renice) };
267 if result == -1 {
268 eprintln!("warn: failed to renice process, ignored");
269 }
270 }
271
272 fn poll_cloud_info(&mut self) {
273 if self.cloud_info.is_none()
274 && let Some(ref rx) = self.cloud_rx
275 && let Ok(info) = rx.try_recv()
276 {
277 self.cloud_info = Some(info);
278 }
279 }
280
281 fn collect_sample(&mut self) -> Sample {
282 let loop_start = Instant::now();
283
284 let actual_interval_ms: Option<u64> = self
285 .prev_loop_start
286 .map(|p| u64::try_from((loop_start - p).as_millis()).unwrap_or(u64::MAX));
287
288 let timestamp_secs = SystemTime::now()
289 .duration_since(UNIX_EPOCH)
290 .unwrap_or_default()
291 .as_secs();
292
293 let mut sample = Sample {
294 timestamp_secs,
295 actual_interval_ms,
296 job_name: self.config.metadata.job_name.clone(),
297 tracked_pid: self.config.pid,
298 cpu: self.cpu.collect().unwrap_or_default(),
299 memory: self.memory.collect().unwrap_or_default(),
300 network: self.network.collect().unwrap_or_default(),
301 disk: self.disk.collect().unwrap_or_default(),
302 gpu: self.gpu.collect().unwrap_or_default(),
303 };
304
305 let (vram_mib, gpu_usage, gpu_utilized) =
307 if self.config.pid.is_some() && !sample.cpu.process_tree_pids.is_empty() {
308 let pids_u32: Vec<u32> = sample
309 .cpu
310 .process_tree_pids
311 .iter()
312 .filter_map(|&p| u32::try_from(p).ok())
313 .collect();
314 self.gpu.process_gpu_info(&pids_u32, self.interval)
315 } else {
316 self.gpu.all_gpu_process_info(self.interval)
317 };
318 sample.cpu.process_gpu_vram_mib = vram_mib;
319 sample.cpu.process_gpu_usage = gpu_usage;
320 sample.cpu.process_gpu_utilized = gpu_utilized;
321
322 self.prev_loop_start = Some(loop_start);
323
324 sample
325 }
326
327 fn emit_sample(&mut self, sample: &Sample) {
328 match self.config.format {
329 OutputFormat::Json => match serde_json::to_value(sample) {
330 Ok(mut v) => {
331 v[format!("{}-version", env!("CARGO_PKG_NAME"))] =
332 serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string());
333 Self::emit_metric_line(&self.config, &mut self.out_file, &v.to_string());
334 }
335 Err(e) => eprintln!("warn: json serialize error: {e}"),
336 },
337
338 OutputFormat::Csv => {
339 Self::emit_metric_line(
340 &self.config,
341 &mut self.out_file,
342 &output::csv::sample_to_csv_row(sample, self.config.interval_secs),
343 );
344 }
345 }
346 }
347
348 fn buffer_sample(&mut self, sample: Sample) {
349 if let Some(ref buf) = self.sample_buffer {
351 buf.lock()
352 .unwrap_or_else(|e| e.into_inner())
353 .push(sample.clone());
354 }
355 self.unflushed.push(sample);
356 }
357
358 fn check_child_exit(&mut self) -> Option<i32> {
359 let child = self.child.as_mut()?;
360
361 match child.try_wait() {
362 Ok(Some(status)) => Some(status.code().unwrap_or(1)),
363 Ok(None) => None,
364 Err(e) => {
365 eprintln!("warn: error checking child status: {e}");
366 None
367 }
368 }
369 }
370
371 fn check_signal(&self) -> bool {
372 SIGTERM_RECEIVED.load(Ordering::Relaxed)
373 }
374
375 fn sleep_until_next_interval(&self, loop_start: Instant) {
376 let elapsed = loop_start.elapsed();
377 if let Some(remaining) = self.interval.checked_sub(elapsed) {
378 std::thread::sleep(remaining);
379 }
380 }
381
382 fn shutdown(&mut self, exit_code: i32) -> ! {
383 let sentinel = self.sentinel.take();
385 let run_ctx = self.run_ctx_arc.take();
386 let shutdown_flag = self.upload_shutdown_flag.take();
387 let upload_handle = self.upload_handle.take();
388 let remaining = std::mem::take(&mut self.unflushed);
389
390 Self::graceful_shutdown(
391 exit_code,
392 sentinel.as_ref(),
393 run_ctx,
394 shutdown_flag,
395 upload_handle,
396 remaining,
397 self.config.interval_secs,
398 );
399 }
400
401 fn run(mut self) -> ! {
402 self.warmup_collectors();
403 std::thread::sleep(self.interval);
404
405 self.spawn_tracked_command();
406 self.mask_sensitive_data_in_command();
407 self.setup_sentinel();
408 self.emit_csv_header();
409 self.renice_tracker();
410
411 loop {
413 self.poll_cloud_info();
414 let loop_start = Instant::now();
415
416 let sample = self.collect_sample();
417 self.emit_sample(&sample);
418 self.buffer_sample(sample);
419
420 if let Some(code) = self.check_child_exit() {
421 self.shutdown(code);
422 }
423 if self.check_signal() {
424 self.shutdown(0);
425 }
426
427 self.sleep_until_next_interval(loop_start);
428 }
429 }
430
431 fn create_sink(config: &Config) -> Option<BufWriter<File>> {
437 if config.quiet {
438 return None;
439 }
440
441 match config.output_file.as_deref() {
442 Some(path) => File::create(path).map(BufWriter::new).ok(),
443 None => None,
444 }
445 }
446
447 fn graceful_shutdown(
458 exit_code: i32,
459 sentinel: Option<&SentinelClient>,
460 run_ctx: Option<Arc<Mutex<RunContext>>>,
461 shutdown_flag: Option<Arc<AtomicBool>>,
462 upload_handle: Option<std::thread::JoinHandle<Vec<String>>>,
463 remaining: Vec<Sample>,
464 interval_secs: u64,
465 ) -> ! {
466 if let (Some(client), Some(ctx_arc), Some(flag), Some(handle)) =
467 (sentinel, run_ctx, shutdown_flag, upload_handle)
468 {
469 flag.store(true, Ordering::Relaxed);
473 let uploaded_uris = handle.join().unwrap_or_default();
474
475 let remaining_csv = if uploaded_uris.is_empty() && !remaining.is_empty() {
481 Some(samples_to_csv(&remaining, interval_secs))
482 } else {
483 None
484 };
485
486 let ctx = ctx_arc.lock().unwrap_or_else(|e| e.into_inner());
487 if let Err(e) = close_run(
488 &client.agent,
489 &client.api_base,
490 &client.token,
491 &ctx,
492 Some(exit_code),
493 remaining_csv,
494 &uploaded_uris,
495 ) {
496 eprintln!("warn: sentinel close_run failed: {e}");
497 }
498 }
499
500 std::process::exit(exit_code);
501 }
502
503 fn emit_metric_line(config: &Config, out_file: &mut Option<BufWriter<File>>, msg: &str) {
509 if config.quiet {
510 return;
511 }
512
513 match out_file {
514 Some(writer) => {
515 let _ = writeln!(writer, "{msg}");
516 let _ = writer.flush();
517 }
518 None => eprintln!("{msg}"),
519 }
520 }
521}
522
523fn main() {
528 setup_signal_handlers();
529 let tracker = ResourceTracker::new();
530 tracker.run();
531}
532
533#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
545 fn test_sigint_sets_shutdown_flag() {
546 SIGTERM_RECEIVED.store(false, Ordering::SeqCst);
548
549 unsafe {
551 libc::signal(
552 libc::SIGINT,
553 handle_sigterm as *const () as libc::sighandler_t,
554 );
555 }
556
557 unsafe {
559 libc::raise(libc::SIGINT);
560 }
561
562 assert!(
563 SIGTERM_RECEIVED.load(Ordering::SeqCst),
564 "SIGTERM_RECEIVED flag must be true after SIGINT"
565 );
566
567 SIGTERM_RECEIVED.store(false, Ordering::SeqCst);
570 unsafe {
571 libc::signal(libc::SIGINT, libc::SIG_DFL);
572 }
573 }
574
575 fn test_redact(data: &str, contains: Option<&str>) {
576 let result = ResourceTracker::try_redact(data);
577
578 match (contains, result) {
579 (Some(expected), Some(redacted)) => {
580 assert!(
581 redacted.contains(expected),
582 "expected to be redacted, got: {redacted}"
583 );
584 }
585 (Some(_), None) => {
586 panic!("expected to be redacted, but not detected");
587 }
588 (None, Some(redacted)) => {
589 panic!("expected to be unchanged, got {redacted}");
590 }
591 (None, None) => (),
592 }
593 }
594
595 #[test]
598 fn test_redact_email_plain_good() {
599 test_redact("sample@example.com", Some("[EMAIL]"));
600 }
601
602 #[test]
603 fn test_redact_email_dot_in_username() {
604 test_redact("good.sample@example.com", Some("[EMAIL]"));
605 }
606
607 #[test]
608 fn test_redact_email_twitter_style() {
609 test_redact("@twitternick", None);
610 }
611
612 #[test]
613 fn test_redact_email_invalid_host() {
614 test_redact("nick@invalid_host.com", None);
615 }
616
617 #[test]
620 fn test_redact_url_no_tld() {
621 test_redact("http://server04", Some("[URL]")); }
623
624 #[test]
625 fn test_redact_url_http() {
626 test_redact("http://example.com", None); }
628
629 #[test]
630 fn test_redact_url_https() {
631 test_redact("https://example.com/path", None); }
633
634 #[test]
635 fn test_redact_url_https_with_account() {
636 test_redact("https://nick@example.com/path", Some("[")); }
638
639 #[test]
640 fn test_redact_url_with_query_params() {
641 test_redact("https://example.com/page?q=search&lang=en", Some("[URL]"));
642 }
643
644 #[test]
645 fn test_redact_url_with_fragment() {
646 test_redact("https://example.com#section", None); }
648
649 #[test]
650 fn test_redact_url_with_subdomain() {
651 test_redact("https://api.example.com/report/from/otherworld", None); }
653
654 #[test]
655 fn test_redact_url_with_port() {
656 test_redact("https://localhost:8080/admin", Some("[URL]"));
657 }
658
659 #[test]
660 fn test_redact_url_ftp() {
661 test_redact("ftp://ftp.example.com/files", Some("[URL]"));
662 }
663
664 #[test]
665 fn test_redact_url_invalid_no_protocol() {
666 test_redact("example.com", None); }
668
669 #[test]
670 fn test_redact_connection_string() {
671 let raw = "app.py --connection-string 'postgresql://username:ASDAD_32ejae32DWQdw2d2@foobar.db.provider.com:12345/db?sslmode=require'";
672 assert_eq!(
673 ResourceTracker::try_redact(raw).as_deref(),
674 Some("app.py --connection-string [SECRET]")
675 );
676 }
677
678 #[test]
681 fn test_redact_ipv4_standard() {
682 test_redact("192.168.1.1", Some("[IP]"));
683 }
684
685 #[test]
686 fn test_redact_ipv4_with_port() {
687 test_redact("192.168.1.1:8080", Some("[IP]"));
688 }
689
690 #[test]
691 fn test_redact_ipv4_all_zeros() {
692 test_redact("0.0.0.0", Some("[IP]"));
693 }
694
695 #[test]
696 fn test_redact_ipv4_loopback() {
697 test_redact("127.0.0.1", Some("[IP]"));
698 }
699
700 #[test]
701 fn test_redact_ipv4_broadcast() {
702 test_redact("255.255.255.255", Some("[IP]"));
703 }
704
705 #[test]
706 fn test_redact_ip_invalid_octet_overflow() {
707 test_redact("256.168.1.1", None);
708 }
709
710 #[test]
711 fn test_redact_ip_invalid_partial() {
712 test_redact("192.168.1", None);
713 }
714
715 #[test]
718 fn test_redact_ssl_private_key_rsa() {
719 test_redact(
720 "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----",
721 Some("[KEY]"),
722 );
723 }
724
725 #[test]
726 fn test_redact_ssl_cert() {
727 test_redact(
728 "-----BEGIN CERTIFICATE-----\nMIIEowIBAAKCAQEA...\n-----END CERTIFICATE-----",
729 Some("[KEY]"),
730 );
731 }
732
733 #[test]
736 fn test_redact_possible_token() {
737 test_redact("ar4mNbYrVwZuAtJhCf7DgLeW2oI5qR8eMvXn", Some("[SECRET]"));
738 }
739}