use mongodb::bson::doc; use tokio_cron_scheduler::{Job, JobScheduler}; use compliance_core::models::ScanTrigger; use crate::agent::ComplianceAgent; use crate::database::Database; use crate::error::AgentError; /// Default tenant the scheduler runs against when neither the tenant /// registry nor `SCHEDULER_TENANT_IDS` are configured. Matches the /// dev-injector default so a bare `cargo run` has the scheduler /// scanning whatever lives in `_dev`. const DEFAULT_SCHEDULER_TENANT_ID: &str = "dev"; /// Request timeout when fetching the live tenant list from the /// registry. Kept short — if the registry is slow we'd rather fall /// back to env-configured ids and finish the tick than block the /// scheduler loop. const REGISTRY_FETCH_TIMEOUT_SECS: u64 = 5; pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError> { let sched = JobScheduler::new() .await .map_err(|e| AgentError::Scheduler(format!("Failed to create scheduler: {e}")))?; // Periodic scan job let scan_agent = agent.clone(); let scan_schedule = agent.config.scan_schedule.clone(); let scan_job = Job::new_async(scan_schedule.as_str(), move |_uuid, _lock| { let agent = scan_agent.clone(); Box::pin(async move { tracing::info!("Scheduled scan triggered"); let tenants = scheduler_tenants(&agent).await; tracing::debug!( tenant_count = tenants.len(), "Scheduled scan: tenants resolved" ); for tenant_id in tenants { scan_all_repos(&agent, &tenant_id).await; } }) }) .map_err(|e| AgentError::Scheduler(format!("Failed to create scan job: {e}")))?; sched .add(scan_job) .await .map_err(|e| AgentError::Scheduler(format!("Failed to add scan job: {e}")))?; // CVE monitor job (daily) let cve_agent = agent.clone(); let cve_schedule = agent.config.cve_monitor_schedule.clone(); let cve_job = Job::new_async(cve_schedule.as_str(), move |_uuid, _lock| { let agent = cve_agent.clone(); Box::pin(async move { tracing::info!("CVE monitor triggered"); let tenants = scheduler_tenants(&agent).await; tracing::debug!( tenant_count = tenants.len(), "CVE monitor: tenants resolved" ); for tenant_id in tenants { monitor_cves(&agent, &tenant_id).await; } }) }) .map_err(|e| AgentError::Scheduler(format!("Failed to create CVE monitor job: {e}")))?; sched .add(cve_job) .await .map_err(|e| AgentError::Scheduler(format!("Failed to add CVE monitor job: {e}")))?; sched .start() .await .map_err(|e| AgentError::Scheduler(format!("Failed to start scheduler: {e}")))?; let tenants = scheduler_tenants(agent).await; let source = if agent.config.tenant_registry_url.is_some() { "tenant-registry (env fallback)" } else { "env (SCHEDULER_TENANT_IDS)" }; tracing::info!( "Scheduler started: scans='{}', CVE monitor='{}', tenant source={source}, tenants={tenants:?}", agent.config.scan_schedule, agent.config.cve_monitor_schedule, ); // Keep scheduler alive loop { tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; } } /// Tenants the scheduler iterates each tick. /// /// Resolution order: /// 1. **Tenant registry** at `agent.config.tenant_registry_url` /// (`GET /v1/tenants`). Fresh on every tick — picks up newly /// provisioned tenants without an agent restart. /// 2. **`SCHEDULER_TENANT_IDS`** env (comma-separated) — fallback when /// the registry is unreachable, the response is malformed, or no /// registry URL is configured. /// 3. **`DEFAULT_SCHEDULER_TENANT_ID`** (`"dev"`) — last-ditch fallback /// so the scheduler keeps doing something useful in dev. /// /// We never panic out of this function — the scheduler must keep /// firing even if the registry is offline. async fn scheduler_tenants(agent: &ComplianceAgent) -> Vec { if let Some(url) = agent.config.tenant_registry_url.as_deref() { match fetch_tenants_from_registry(&agent.http, url).await { Ok(v) if !v.is_empty() => return v, Ok(_) => { tracing::warn!("tenant-registry returned empty list; falling back to env"); } Err(e) => { tracing::warn!( url = %url, error = %e, "tenant-registry fetch failed; falling back to env" ); } } } tenants_from_env() } fn tenants_from_env() -> Vec { std::env::var("SCHEDULER_TENANT_IDS") .ok() .map(|s| { s.split(',') .map(str::trim) .filter(|s| !s.is_empty()) .map(String::from) .collect::>() }) .filter(|v| !v.is_empty()) .unwrap_or_else(|| vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()]) } /// Shape we accept from the registry. Liberal in what we accept: /// the registry can return any field shape as long as either `id` or /// `tenant_id` is present. Other fields are ignored. #[derive(serde::Deserialize)] struct RegistryTenant { #[serde(alias = "tenant_id")] id: String, /// Filter out non-running tenants if status is present. Missing /// status defaults to "active" so older registry deployments keep /// working. #[serde(default = "default_status")] status: String, } fn default_status() -> String { "active".to_string() } #[derive(serde::Deserialize)] struct RegistryListResponse { data: Vec, } async fn fetch_tenants_from_registry( http: &reqwest::Client, base_url: &str, ) -> Result, String> { let url = format!("{}/v1/tenants", base_url.trim_end_matches('/')); let resp = http .get(&url) .timeout(std::time::Duration::from_secs(REGISTRY_FETCH_TIMEOUT_SECS)) .send() .await .map_err(|e| format!("request failed: {e}"))?; if !resp.status().is_success() { return Err(format!("registry returned {}", resp.status())); } let body: RegistryListResponse = resp .json() .await .map_err(|e| format!("invalid JSON: {e}"))?; Ok(filter_active(body.data)) } /// Frozen/Archived tenants don't need scheduled scans; the M7.1 /// status gate would 402/410 anyway. Skip them so we don't waste /// cycles. Active / trial / demo / anything-else-unknown all run. fn filter_active(rows: Vec) -> Vec { rows.into_iter() .filter(|t| !matches!(t.status.as_str(), "frozen" | "archived")) .map(|t| t.id) .collect() } #[cfg(test)] mod tests { use super::*; fn tenant(id: &str, status: &str) -> RegistryTenant { RegistryTenant { id: id.to_string(), status: status.to_string(), } } #[test] fn filter_active_keeps_running_skips_frozen_archived() { let rows = vec![ tenant("a", "active"), tenant("b", "trial"), tenant("c", "demo"), tenant("d", "frozen"), tenant("e", "archived"), tenant("f", "weird-but-not-known-dead"), ]; let out = filter_active(rows); assert_eq!(out, vec!["a", "b", "c", "f"]); } #[test] fn deserialize_registry_response_accepts_id_or_tenant_id() { let body = r#"{"data":[ {"id":"a","status":"active"}, {"tenant_id":"b","status":"trial"}, {"id":"c"} ]}"#; let parsed: RegistryListResponse = serde_json::from_str(body).unwrap(); assert_eq!(parsed.data.len(), 3); assert_eq!(parsed.data[0].id, "a"); assert_eq!(parsed.data[1].id, "b"); assert_eq!(parsed.data[2].id, "c"); // Default status for the third entry should be "active" assert_eq!(parsed.data[2].status, "active"); } /// Combined into a single test: cargo runs tests in parallel and /// env vars are process-global, so two separate tests touching /// `SCHEDULER_TENANT_IDS` race each other. Doing both checks in /// one test keeps them in a deterministic order. #[test] fn tenants_from_env_resolution() { std::env::remove_var("SCHEDULER_TENANT_IDS"); assert_eq!( tenants_from_env(), vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()], "unset → default" ); std::env::set_var("SCHEDULER_TENANT_IDS", "acme, globex ,,hello"); let out = tenants_from_env(); std::env::remove_var("SCHEDULER_TENANT_IDS"); assert_eq!( out, vec!["acme", "globex", "hello"], "splits + trims + drops empty" ); std::env::set_var("SCHEDULER_TENANT_IDS", ""); let out = tenants_from_env(); std::env::remove_var("SCHEDULER_TENANT_IDS"); assert_eq!( out, vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()], "empty → default" ); } } /// Resolve the per-tenant database. Logs and returns `None` on failure /// so the loop in the caller can continue with other tenants. async fn tenant_db(agent: &ComplianceAgent, tenant_id: &str) -> Option { match agent.db_pool.for_tenant_id(tenant_id).await { Ok(db) => Some(db), Err(e) => { tracing::error!("Scheduler: cannot open tenant database '{tenant_id}': {e}"); None } } } async fn scan_all_repos(agent: &ComplianceAgent, tenant_id: &str) { use futures_util::StreamExt; let db = match tenant_db(agent, tenant_id).await { Some(db) => db, None => return, }; let cursor = match db.repositories().find(doc! {}).await { Ok(c) => c, Err(e) => { tracing::error!("Failed to list repos for tenant '{tenant_id}': {e}"); return; } }; let repos: Vec<_> = cursor.filter_map(|r| async { r.ok() }).collect().await; for repo in repos { let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); if let Err(e) = agent .run_scan(tenant_id, &repo_id, ScanTrigger::Scheduled) .await { tracing::error!( "Scheduled scan failed for {} (tenant '{tenant_id}'): {e}", repo.name ); } } } async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) { use compliance_core::models::notification::{parse_severity, CveNotification}; use compliance_core::models::SbomEntry; use futures_util::StreamExt; let db = match tenant_db(agent, tenant_id).await { Some(db) => db, None => return, }; // Fetch all SBOM entries grouped by repo let cursor = match db.sbom_entries().find(doc! {}).await { Ok(c) => c, Err(e) => { tracing::error!("CVE monitor: failed to list SBOM entries for '{tenant_id}': {e}"); return; } }; let entries: Vec = cursor.filter_map(|r| async { r.ok() }).collect().await; if entries.is_empty() { tracing::debug!("CVE monitor: no SBOM entries for tenant '{tenant_id}', skipping"); return; } tracing::info!( "CVE monitor: checking {} dependencies for new CVEs (tenant '{tenant_id}')", entries.len() ); // Build a repo_id → repo_name lookup let repo_ids: std::collections::HashSet = entries.iter().map(|e| e.repo_id.clone()).collect(); let mut repo_names: std::collections::HashMap = std::collections::HashMap::new(); for rid in &repo_ids { if let Ok(oid) = mongodb::bson::oid::ObjectId::parse_str(rid) { if let Ok(Some(repo)) = db.repositories().find_one(doc! { "_id": oid }).await { repo_names.insert(rid.clone(), repo.name.clone()); } } } // Use the existing CveScanner to query OSV.dev let nvd_key = agent.config.nvd_api_key.as_ref().map(|k| { use secrecy::ExposeSecret; k.expose_secret().to_string() }); let scanner = crate::pipeline::cve::CveScanner::new( agent.http.clone(), agent.config.searxng_url.clone(), nvd_key, ); // Group entries by repo for scanning let mut entries_by_repo: std::collections::HashMap> = std::collections::HashMap::new(); for entry in entries { entries_by_repo .entry(entry.repo_id.clone()) .or_default() .push(entry); } let mut new_notifications = 0u32; for (repo_id, mut repo_entries) in entries_by_repo { let repo_name = repo_names .get(&repo_id) .cloned() .unwrap_or_else(|| repo_id.clone()); // Scan dependencies for CVEs let alerts = match scanner.scan_dependencies(&repo_id, &mut repo_entries).await { Ok(a) => a, Err(e) => { tracing::warn!("CVE monitor: scan failed for {repo_name}: {e}"); continue; } }; // Upsert CVE alerts (existing logic) for alert in &alerts { let filter = doc! { "cve_id": &alert.cve_id, "repo_id": &alert.repo_id }; let update = doc! { "$setOnInsert": mongodb::bson::to_bson(alert).unwrap_or_default() }; let _ = db .cve_alerts() .update_one(filter, update) .upsert(true) .await; } // Update SBOM entries with discovered vulnerabilities for entry in &repo_entries { if entry.known_vulnerabilities.is_empty() { continue; } if let Some(entry_id) = &entry.id { let _ = db .sbom_entries() .update_one( doc! { "_id": entry_id }, doc! { "$set": { "known_vulnerabilities": mongodb::bson::to_bson(&entry.known_vulnerabilities).unwrap_or_default(), "updated_at": mongodb::bson::DateTime::now(), }}, ) .await; } } // Create notifications for NEW CVEs (dedup against existing notifications) for alert in &alerts { let filter = doc! { "cve_id": &alert.cve_id, "repo_id": &alert.repo_id, "package_name": &alert.affected_package, "package_version": &alert.affected_version, }; // Only insert if not already exists (upsert with $setOnInsert) let severity = parse_severity(alert.severity.as_deref(), alert.cvss_score); let mut notification = CveNotification::new( alert.cve_id.clone(), repo_id.clone(), repo_name.clone(), alert.affected_package.clone(), alert.affected_version.clone(), severity, ); notification.cvss_score = alert.cvss_score; notification.summary = alert.summary.clone(); notification.url = Some(format!("https://osv.dev/vulnerability/{}", alert.cve_id)); let update = doc! { "$setOnInsert": mongodb::bson::to_bson(¬ification).unwrap_or_default() }; match db .cve_notifications() .update_one(filter, update) .upsert(true) .await { Ok(result) if result.upserted_id.is_some() => { new_notifications += 1; } Err(e) => { tracing::warn!("CVE monitor: failed to create notification: {e}"); } _ => {} // Already exists } } } if new_notifications > 0 { tracing::info!( "CVE monitor: created {new_notifications} new notification(s) for tenant '{tenant_id}'" ); } else { tracing::info!("CVE monitor: no new CVEs found for tenant '{tenant_id}'"); } }