From 57d069e7f09915c9036b4f5bbe815f62a038b7f0 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:24:02 +0800 Subject: [PATCH] feat: bind cron schedule provenance to its own slot --- crates/dockermap-core/src/lib.rs | 36 +++ crates/dockermap-core/src/models.rs | 34 ++- crates/dockermap-core/src/schema_baseline.rs | 27 +- crates/dockermap-core/src/snapshot_runtime.rs | 6 +- crates/dockermap-daemon/src/cache_refresh.rs | 263 +++++++++++++++++- .../dockermap-daemon/src/provider_contract.rs | 4 + crates/dockermap-daemon/src/providers/cron.rs | 27 +- .../src/runtime_collection.rs | 55 +++- .../rust/findings-response.schema.json | 15 +- .../generated/rust/runtime-map.schema.json | 19 +- packages/contracts/src/rustModels.ts | 23 +- packages/contracts/src/rustSchemas.ts | 68 ++++- .../contracts/runtime-map-daemon-emitted.json | 10 + .../contracts/runtime-map-expanded.json | 1 + tests/fixtures/contracts/runtime-map.json | 1 + 15 files changed, 543 insertions(+), 46 deletions(-) diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index b8c60017..66dcaaf1 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -1007,6 +1007,42 @@ mod tests { assert!(serde_json::from_value::(wrong_target).is_err()); } + #[test] + fn version_four_cron_evidence_requires_its_closed_slot_and_canonical_edge() { + let valid = serde_json::json!({ + "version": 4, + "id": "cron_evidence_schedule_opaque", + "provider": "cron", + "kind": "cron_schedule_declaration", + "assertionKind": "declared", + "summary": "cron declared a scheduled job", + "subjectRef": "scheduled_job_opaque", + "collectedAt": 42, + "providerRevision": "opaque-cron-revision", + "providerSlot": "cron", + "freshness": "stale" + }); + assert!(serde_json::from_value::(valid.clone()).is_ok()); + for (field, invalid) in [ + ("providerSlot", serde_json::json!("host_scoped")), + ("provider", serde_json::json!("systemd")), + ("assertionKind", serde_json::json!("observed")), + ("kind", serde_json::json!("systemd_requires")), + ] { + let mut malformed = valid.clone(); + malformed[field] = invalid; + assert!(serde_json::from_value::(malformed).is_err()); + } + let edge = serde_json::json!({ + "source": "scheduled_job_opaque", "target": "host_local", "relationship": "runs_on", + "metadata": {}, "evidenceRefs": [valid] + }); + assert!(serde_json::from_value::(edge.clone()).is_ok()); + let mut wrong_target = edge; + wrong_target["target"] = serde_json::json!("host_other"); + assert!(serde_json::from_value::(wrong_target).is_err()); + } + #[test] fn version_one_evidence_cannot_attest_a_different_runtime_edge() { let snapshot = mock_snapshot(); diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index d53a4ac8..401ef79c 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -153,6 +153,9 @@ pub enum RuntimeMode { pub enum ProviderSlot { NetworkInfrastructure, HostScoped, + /// Cron has an independent collector lifecycle. It must not inherit + /// host-node, listener, PM2, or tmux freshness. + Cron, /// systemd has an independent collector lifecycle. It must not inherit /// freshness from the broader host-scoped observation slot. Systemd, @@ -829,6 +832,7 @@ pub enum RuntimeEvidenceProvider { Docker, Systemd, Npm, + Cron, } /// Evidence assertion semantics are deliberately closed. A declaration says @@ -866,6 +870,8 @@ pub enum RuntimeEvidenceKind { /// A package.json dependency declaration. This is not proof that the /// package was installed, resolved, executed, or is safe. NpmPackageManifestDependency, + /// A parsed cron declaration. This does not claim the command ran. + CronScheduleDeclaration, } /// A compact, versioned reference to the bounded fact supporting a runtime @@ -876,7 +882,7 @@ pub enum RuntimeEvidenceKind { pub struct RuntimeEvidenceRef { /// Version of this closed evidence representation, not a provider API /// version. It lets future additions remain explicit and reviewable. - #[schemars(range(min = 1, max = 3))] + #[schemars(range(min = 1, max = 4))] pub version: u8, #[schemars(length(min = 1, max = 259))] pub id: String, @@ -957,6 +963,15 @@ impl RuntimeEvidenceRef { | RuntimeEvidenceFreshness::Stale | RuntimeEvidenceFreshness::TimedOut, Some(ProviderSlot::ProjectNpm), + ) | ( + 4, + RuntimeEvidenceProvider::Cron, + RuntimeEvidenceKind::CronScheduleDeclaration, + RuntimeEvidenceAssertionKind::Declared, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::Cron), ) ) } @@ -1151,6 +1166,21 @@ impl RuntimeMapEdge { && self.target.starts_with("npm_package_") && self.source != self.target } + ( + 4, + RuntimeEvidenceProvider::Cron, + RuntimeEvidenceKind::CronScheduleDeclaration, + RuntimeEvidenceAssertionKind::Declared, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::Cron), + ) => { + self.relationship == RuntimeRelationshipKind::RunsOn + && self.source.starts_with("scheduled_job_") + && self.target == "host_local" + && self.source != self.target + } ( 2, RuntimeEvidenceProvider::Systemd, @@ -1292,7 +1322,7 @@ pub struct RuntimeMap { #[schemars(length(min = 1))] pub model_revision: String, #[serde(rename = "providerStates")] - #[schemars(length(min = 6, max = 6))] + #[schemars(length(min = 7, max = 7))] pub provider_states: Vec, /// ACTUAL source of these bytes: "docker" or "mock" (#85 A3). Stamped by /// the daemon route layer from the cache's runtime mode. diff --git a/crates/dockermap-core/src/schema_baseline.rs b/crates/dockermap-core/src/schema_baseline.rs index 1dc23715..be5ea895 100644 --- a/crates/dockermap-core/src/schema_baseline.rs +++ b/crates/dockermap-core/src/schema_baseline.rs @@ -162,11 +162,34 @@ mod tests { .expect("provider state property exists"); assert_eq!( states.get("minItems").and_then(|value| value.as_u64()), - Some(6) + Some(7) ); assert_eq!( states.get("maxItems").and_then(|value| value.as_u64()), - Some(6) + Some(7) + ); + } + + #[test] + fn runtime_evidence_schema_admits_the_closed_version_four_cron_shape() { + let schema = DAEMON_SCHEMA_NAMES + .iter() + .zip(daemon_schema_documents()) + .find_map(|(name, schema)| (*name == "RuntimeMap").then_some(schema)) + .expect("runtime map schema exists"); + let evidence = schema + .pointer("/$defs/RuntimeEvidenceRef") + .expect("runtime evidence definition exists"); + assert_eq!( + evidence + .pointer("/properties/version/maximum") + .and_then(|value| value.as_u64()), + Some(4), + "generated schema must not reject the newest closed evidence version" + ); + assert!( + evidence.pointer("/properties/provider/$ref").is_some(), + "provider stays a closed generated enum" ); } diff --git a/crates/dockermap-core/src/snapshot_runtime.rs b/crates/dockermap-core/src/snapshot_runtime.rs index 9322ac20..8ae43169 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -393,7 +393,8 @@ fn docker_runtime_evidence( RuntimeEvidenceKind::SystemdRequires | RuntimeEvidenceKind::SystemdWants | RuntimeEvidenceKind::SystemdPartOf - | RuntimeEvidenceKind::NpmPackageManifestDependency => { + | RuntimeEvidenceKind::NpmPackageManifestDependency + | RuntimeEvidenceKind::CronScheduleDeclaration => { unreachable!("Docker evidence helper only accepts Docker evidence kinds") } }; @@ -412,7 +413,8 @@ fn docker_runtime_evidence( RuntimeEvidenceKind::SystemdRequires | RuntimeEvidenceKind::SystemdWants | RuntimeEvidenceKind::SystemdPartOf - | RuntimeEvidenceKind::NpmPackageManifestDependency => { + | RuntimeEvidenceKind::NpmPackageManifestDependency + | RuntimeEvidenceKind::CronScheduleDeclaration => { unreachable!("Docker evidence helper only accepts Docker evidence kinds") } }; diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index d49dc70b..f7c52038 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -8,6 +8,7 @@ use crate::{ docker_collector::DockerCollector, provider_contract::ProviderCollection, + providers::cron::CRON_EVIDENCE_SCHEDULE_MARKER, providers::npm::NPM_EVIDENCE_DEPENDENCY_MARKER, providers::systemd::{ SYSTEMD_EVIDENCE_KIND_MARKER, SYSTEMD_EVIDENCE_PART_OF, SYSTEMD_EVIDENCE_REQUIRES, @@ -309,6 +310,7 @@ impl SlotDataRevision { pub(crate) struct ProviderSlotFlights { network: Arc, host: Arc, + cron: Arc, systemd: Arc, python: Arc, native: Arc, @@ -320,6 +322,7 @@ impl Default for ProviderSlotFlights { Self { network: Arc::new(AtomicBool::new(false)), host: Arc::new(AtomicBool::new(false)), + cron: Arc::new(AtomicBool::new(false)), systemd: Arc::new(AtomicBool::new(false)), python: Arc::new(AtomicBool::new(false)), native: Arc::new(AtomicBool::new(false)), @@ -336,6 +339,7 @@ impl ProviderSlotFlights { match slot { ProviderSlot::NetworkInfrastructure => self.network.clone(), ProviderSlot::HostScoped => self.host.clone(), + ProviderSlot::Cron => self.cron.clone(), ProviderSlot::Systemd => self.systemd.clone(), ProviderSlot::PythonProcesses => self.python.clone(), ProviderSlot::NativeProcesses => self.native.clone(), @@ -347,6 +351,7 @@ impl ProviderSlotFlights { [ &self.network, &self.host, + &self.cron, &self.systemd, &self.python, &self.native, @@ -881,6 +886,8 @@ fn runtime_map_for_snapshot( bind_systemd_evidence(&mut edges, slot_state); } else if slot == ProviderSlot::ProjectNpm { bind_npm_evidence(&mut edges, slot_state); + } else if slot == ProviderSlot::Cron { + bind_cron_evidence(&mut edges, slot_state); } let (target_nodes, target_edges, target_diagnostics) = combined.parts_mut(); target_nodes.extend(nodes); @@ -895,6 +902,22 @@ fn runtime_map_for_snapshot( }); } } + // Cron's declaration target is canonical only when the independently + // retained HostScoped observation supplied `host_local`. Startup and host + // refresh ordering can otherwise leave a dangling relationship; omit it + // rather than publishing an unverifiable target or borrowing host state. + let has_canonical_host = combined.nodes().iter().any(|node| { + node.id == "host_local" + && node.provider == RuntimeProviderKind::Host + && node.kind == dockermap_core::RuntimeNodeKind::Host + }); + if !has_canonical_host { + combined.parts_mut().1.retain(|edge| { + !(edge.source.starts_with("scheduled_job_") + && edge.target == "host_local" + && edge.relationship == dockermap_core::RuntimeRelationshipKind::RunsOn) + }); + } let mut runtime_map = runtime_map_from_collection(snapshot, &combined, docker_observation_revision, mode); runtime_map.provider_states = provider_states_for(slots); @@ -902,6 +925,84 @@ fn runtime_map_for_snapshot( runtime_map } +/// Bind a parsed cron declaration only to the independently scheduled Cron +/// slot. The private marker is removed in every path. A cron relationship is +/// fail-closed until the canonical retained host node exists. +fn bind_cron_evidence(edges: &mut [RuntimeMapEdge], state: &SlotRuntimeState) { + let disabled = retained_collection(&state.observation) + .as_ref() + .is_some_and(|collection| { + collection.states().iter().any(|candidate| { + candidate.slot == ProviderSlot::Cron + && candidate.state == ProviderStateKind::Disabled + }) + }); + let freshness = match &state.observation { + RuntimeProviderState::Fresh(_) => RuntimeEvidenceFreshness::Fresh, + RuntimeProviderState::Collecting(Some(_)) | RuntimeProviderState::Degraded(Some(_)) => { + RuntimeEvidenceFreshness::Stale + } + RuntimeProviderState::TimedOut(Some(_)) => RuntimeEvidenceFreshness::TimedOut, + RuntimeProviderState::Unavailable + | RuntimeProviderState::Collecting(None) + | RuntimeProviderState::Degraded(None) + | RuntimeProviderState::TimedOut(None) => { + clear_cron_evidence(edges); + return; + } + }; + let (Some(revision), Some(collected_at)) = ( + state + .freshness + .data_revision + .as_ref() + .map(SlotDataRevision::public), + state.freshness.last_success_ms, + ) else { + clear_cron_evidence(edges); + return; + }; + if disabled { + clear_cron_evidence(edges); + return; + } + for edge in edges.iter_mut() { + let marker = edge.metadata.remove(CRON_EVIDENCE_SCHEDULE_MARKER); + if marker.as_deref() != Some("declared") + || edge.relationship != dockermap_core::RuntimeRelationshipKind::RunsOn + || !edge.source.starts_with("scheduled_job_") + || edge.target != "host_local" + || edge.source == edge.target + { + edge.evidence_refs.clear(); + continue; + } + edge.evidence_refs = vec![RuntimeEvidenceRef { + version: 4, + id: format!( + "cron_evidence_schedule_{}", + collision_resistant_id_component(&format!("{}\u{1f}{}", edge.source, edge.target)) + ), + provider: RuntimeEvidenceProvider::Cron, + kind: RuntimeEvidenceKind::CronScheduleDeclaration, + assertion_kind: RuntimeEvidenceAssertionKind::Declared, + summary: "cron declared a scheduled job".into(), + subject_ref: edge.source.clone(), + collected_at, + provider_revision: revision.clone(), + provider_slot: Some(ProviderSlot::Cron), + freshness, + }]; + } +} + +fn clear_cron_evidence(edges: &mut [RuntimeMapEdge]) { + for edge in edges.iter_mut() { + edge.metadata.remove(CRON_EVIDENCE_SCHEDULE_MARKER); + edge.evidence_refs.clear(); + } +} + /// Convert the private NPM manifest marker into public evidence only after /// this exact ProjectNpm slot has a sanitized opaque revision and successful /// collection timestamp. Retention is explicit: stale/timed-out observations @@ -1360,6 +1461,156 @@ mod scheduler_tests { collection } + fn marked_cron_declaration() -> ProviderCollection { + let mut collection = ProviderCollection::default(); + collection.set_state(ProviderSlot::Cron, ProviderStateKind::Fresh); + collection.nodes_mut().push(RuntimeMapNode { + id: "scheduled_job_declared".into(), + provider: RuntimeProviderKind::ScheduledJob, + kind: RuntimeNodeKind::ScheduledJob, + label: "scheduled job".into(), + status: Some("scheduled".into()), + layer: Some(RuntimeNodeLayer::Process), + metadata: BTreeMap::new(), + service: None, + package: None, + }); + collection.parts_mut().1.push(RuntimeMapEdge { + source: "scheduled_job_declared".into(), + target: "host_local".into(), + relationship: dockermap_core::RuntimeRelationshipKind::RunsOn, + metadata: BTreeMap::from([(CRON_EVIDENCE_SCHEDULE_MARKER.into(), "declared".into())]), + evidence_refs: Vec::new(), + }); + collection + } + + fn host_collection() -> ProviderCollection { + let mut collection = ProviderCollection::default(); + collection.set_state(ProviderSlot::HostScoped, ProviderStateKind::Fresh); + collection.nodes_mut().push(RuntimeMapNode { + id: "host_local".into(), + provider: RuntimeProviderKind::Host, + kind: RuntimeNodeKind::Host, + label: "host".into(), + status: Some("online".into()), + layer: Some(RuntimeNodeLayer::Host), + metadata: BTreeMap::new(), + service: None, + package: None, + }); + collection + } + + #[test] + fn cron_declaration_evidence_is_slot_bound_and_target_gated() { + for (observation, expected) in [ + ( + RuntimeProviderState::Fresh(marked_cron_declaration()), + RuntimeEvidenceFreshness::Fresh, + ), + ( + RuntimeProviderState::Degraded(Some(marked_cron_declaration())), + RuntimeEvidenceFreshness::Stale, + ), + ( + RuntimeProviderState::TimedOut(Some(marked_cron_declaration())), + RuntimeEvidenceFreshness::TimedOut, + ), + ] { + let mut provider_slots = slots(); + let cron = provider_slots.get_mut(&ProviderSlot::Cron).unwrap(); + cron.observation = observation; + cron.freshness.data_revision = Some(SlotDataRevision::first()); + cron.freshness.last_success_ms = Some(42); + // A completed Cron pass alone must not publish a dangling edge. + let no_host = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &provider_slots, + "docker-observation", + ); + assert!(no_host + .edges + .iter() + .all(|edge| edge.source != "scheduled_job_declared")); + + let host = provider_slots.get_mut(&ProviderSlot::HostScoped).unwrap(); + host.observation = RuntimeProviderState::Fresh(host_collection()); + host.freshness.data_revision = Some(SlotDataRevision::first()); + host.freshness.last_success_ms = Some(42); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &provider_slots, + "docker-observation", + ); + let edge = map + .edges + .iter() + .find(|edge| edge.source == "scheduled_job_declared") + .expect("canonical host admits cron edge"); + assert!(edge.metadata.is_empty()); + assert_eq!(edge.evidence_refs.len(), 1); + let evidence = &edge.evidence_refs[0]; + assert_eq!(evidence.version, 4); + assert_eq!(evidence.provider, RuntimeEvidenceProvider::Cron); + assert_eq!(evidence.kind, RuntimeEvidenceKind::CronScheduleDeclaration); + assert_eq!( + evidence.assertion_kind, + RuntimeEvidenceAssertionKind::Declared + ); + assert_eq!(evidence.provider_slot, Some(ProviderSlot::Cron)); + assert_eq!(evidence.collected_at, 42); + assert_eq!(evidence.freshness, expected); + } + } + + #[test] + fn cron_marker_never_publishes_without_lifecycle_or_after_reset() { + let mut provider_slots = slots(); + let cron = provider_slots.get_mut(&ProviderSlot::Cron).unwrap(); + cron.observation = RuntimeProviderState::Fresh(marked_cron_declaration()); + let host = provider_slots.get_mut(&ProviderSlot::HostScoped).unwrap(); + host.observation = RuntimeProviderState::Fresh(host_collection()); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &provider_slots, + "docker-observation", + ); + let edge = map + .edges + .iter() + .find(|edge| edge.source == "scheduled_job_declared") + .unwrap(); + assert!(edge.evidence_refs.is_empty() && edge.metadata.is_empty()); + + let mut disabled = slots(); + let cron = disabled.get_mut(&ProviderSlot::Cron).unwrap(); + let mut collection = marked_cron_declaration(); + collection.set_state(ProviderSlot::Cron, ProviderStateKind::Disabled); + cron.observation = RuntimeProviderState::Fresh(collection); + cron.freshness.data_revision = Some(SlotDataRevision::first()); + cron.freshness.last_success_ms = Some(42); + disabled + .get_mut(&ProviderSlot::HostScoped) + .unwrap() + .observation = RuntimeProviderState::Fresh(host_collection()); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &disabled, + "docker-observation", + ); + let edge = map + .edges + .iter() + .find(|edge| edge.source == "scheduled_job_declared") + .unwrap(); + assert!(edge.evidence_refs.is_empty() && edge.metadata.is_empty()); + } + #[test] fn systemd_evidence_is_slot_bound_and_truthfully_retained() { for (observation, expected) in [ @@ -1760,6 +2011,7 @@ mod scheduler_tests { slot_interval(ProviderSlot::HostScoped), Duration::from_secs(15) ); + assert_eq!(slot_interval(ProviderSlot::Cron), Duration::from_secs(15)); assert_eq!( slot_interval(ProviderSlot::Systemd), Duration::from_secs(15) @@ -1808,6 +2060,7 @@ mod scheduler_tests { let invocations = |slot| 1 + window.as_secs() / slot_interval(slot).as_secs(); assert_eq!(invocations(ProviderSlot::NetworkInfrastructure), 7); assert_eq!(invocations(ProviderSlot::HostScoped), 5); + assert_eq!(invocations(ProviderSlot::Cron), 5); assert_eq!(invocations(ProviderSlot::Systemd), 5); assert_eq!(invocations(ProviderSlot::PythonProcesses), 7); assert_eq!(invocations(ProviderSlot::NativeProcesses), 7); @@ -1874,11 +2127,12 @@ mod scheduler_tests { assert_eq!(publications, 31); assert_eq!(starts[&ProviderSlot::NetworkInfrastructure], 7); assert_eq!(starts[&ProviderSlot::HostScoped], 5); + assert_eq!(starts[&ProviderSlot::Cron], 5); assert_eq!(starts[&ProviderSlot::Systemd], 5); assert_eq!(starts[&ProviderSlot::PythonProcesses], 7); assert_eq!(starts[&ProviderSlot::NativeProcesses], 7); assert_eq!(starts[&ProviderSlot::ProjectNpm], 2); - assert_eq!(starts.values().sum::(), 33); + assert_eq!(starts.values().sum::(), 38); assert!(maximum_live_workers <= MAX_CONCURRENT_PROVIDER_SLOTS); // Before Systemd became independently schedulable, one aggregate // host-scoped pass covered it alongside the four other fixed bundles. @@ -1911,7 +2165,7 @@ mod scheduler_tests { .block_on(run_real_collector_churn_trace(&profile)); match profile.as_str() { "full-host" => { - assert_eq!(starts.values().sum::(), 33); + assert_eq!(starts.values().sum::(), 38); // The old whole-runtime pass had five aggregate bundles; // systemd was part of host-scoped collection, not a sixth // independently scheduled unit. @@ -1924,8 +2178,9 @@ mod scheduler_tests { ); } "restricted" => { - assert_eq!(starts.values().sum::(), 13); + assert_eq!(starts.values().sum::(), 14); assert_eq!(starts[&ProviderSlot::HostScoped], 1); + assert_eq!(starts[&ProviderSlot::Cron], 1); assert_eq!(starts[&ProviderSlot::Systemd], 1); assert_eq!(starts[&ProviderSlot::PythonProcesses], 1); assert_eq!(starts[&ProviderSlot::NativeProcesses], 1); @@ -2134,6 +2389,7 @@ mod scheduler_tests { if profile == "restricted" { for slot in [ ProviderSlot::HostScoped, + ProviderSlot::Cron, ProviderSlot::PythonProcesses, ProviderSlot::NativeProcesses, ] { @@ -2146,6 +2402,7 @@ mod scheduler_tests { } else { assert_eq!(starts[&ProviderSlot::NetworkInfrastructure], 7); assert_eq!(starts[&ProviderSlot::HostScoped], 5); + assert_eq!(starts[&ProviderSlot::Cron], 5); assert_eq!(starts[&ProviderSlot::Systemd], 5); assert_eq!(starts[&ProviderSlot::PythonProcesses], 7); assert_eq!(starts[&ProviderSlot::NativeProcesses], 7); diff --git a/crates/dockermap-daemon/src/provider_contract.rs b/crates/dockermap-daemon/src/provider_contract.rs index 47605dde..71bab908 100644 --- a/crates/dockermap-daemon/src/provider_contract.rs +++ b/crates/dockermap-daemon/src/provider_contract.rs @@ -49,6 +49,10 @@ pub(crate) struct ProviderCollection { } impl ProviderCollection { + pub(crate) fn nodes(&self) -> &[RuntimeMapNode] { + &self.nodes + } + pub(crate) fn nodes_mut(&mut self) -> &mut Vec { &mut self.nodes } diff --git a/crates/dockermap-daemon/src/providers/cron.rs b/crates/dockermap-daemon/src/providers/cron.rs index 538b2263..8969d004 100644 --- a/crates/dockermap-daemon/src/providers/cron.rs +++ b/crates/dockermap-daemon/src/providers/cron.rs @@ -7,8 +7,8 @@ use crate::process_runner::{run_command_with_timeout, PROVIDER_COMMAND_TIMEOUT}; use crate::{push_provider_diagnostic, redact_sensitive_text, safe_runtime_id_component}; use dockermap_core::{ - DiagnosticSeverity, RuntimeMapDiagnostic, RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, - RuntimeProviderKind, + DiagnosticSeverity, RuntimeMapDiagnostic, RuntimeMapEdge, RuntimeMapNode, RuntimeNodeKind, + RuntimeNodeLayer, RuntimeProviderKind, RuntimeRelationshipKind, }; use std::{ collections::{BTreeMap, BTreeSet}, @@ -22,8 +22,13 @@ use std::{ const MAX_CRON_D_ENTRIES: usize = 64; const MAX_CRON_FILE_BYTES: u64 = 64 * 1024; +/// Private handoff marker. Cache refresh removes it on every path and creates +/// public evidence only after this exact Cron slot owns a successful revision. +pub(crate) const CRON_EVIDENCE_SCHEDULE_MARKER: &str = "__dockermapCronScheduleDeclaration"; + pub(crate) fn collect_scheduled_jobs( nodes: &mut Vec, + edges: &mut Vec, diagnostics: &mut Vec, ) { let mut job_sources = Vec::new(); @@ -60,12 +65,13 @@ pub(crate) fn collect_scheduled_jobs( metadata.insert("source".into(), source.clone()); metadata.insert("line".into(), line.to_string()); metadata.insert("command".into(), safe_command.clone()); + let id = format!( + "scheduled_job_{}_{}", + safe_runtime_id_component(&source, "source"), + safe_runtime_id_component(&format!("{line}_{safe_command}"), "command") + ); nodes.push(RuntimeMapNode { - id: format!( - "scheduled_job_{}_{}", - safe_runtime_id_component(&source, "source"), - safe_runtime_id_component(&format!("{line}_{safe_command}"), "command") - ), + id: id.clone(), provider: RuntimeProviderKind::ScheduledJob, kind: RuntimeNodeKind::ScheduledJob, label: safe_command, @@ -75,6 +81,13 @@ pub(crate) fn collect_scheduled_jobs( service: None, package: None, }); + edges.push(RuntimeMapEdge { + source: id, + target: "host_local".into(), + relationship: RuntimeRelationshipKind::RunsOn, + metadata: BTreeMap::from([(CRON_EVIDENCE_SCHEDULE_MARKER.into(), "declared".into())]), + evidence_refs: Vec::new(), + }); } } diff --git a/crates/dockermap-daemon/src/runtime_collection.rs b/crates/dockermap-daemon/src/runtime_collection.rs index c7e3c13e..534e99e0 100644 --- a/crates/dockermap-daemon/src/runtime_collection.rs +++ b/crates/dockermap-daemon/src/runtime_collection.rs @@ -48,6 +48,7 @@ pub(crate) type StaticProviderSlot = ProviderSlot; pub(crate) const STATIC_PROVIDER_SLOTS: &[StaticProviderSlot] = &[ StaticProviderSlot::NetworkInfrastructure, StaticProviderSlot::HostScoped, + StaticProviderSlot::Cron, StaticProviderSlot::Systemd, StaticProviderSlot::PythonProcesses, StaticProviderSlot::NativeProcesses, @@ -60,6 +61,7 @@ pub(crate) fn slot_interval(slot: StaticProviderSlot) -> Duration { match slot { StaticProviderSlot::NetworkInfrastructure => Duration::from_secs(10), StaticProviderSlot::HostScoped => Duration::from_secs(15), + StaticProviderSlot::Cron => Duration::from_secs(15), StaticProviderSlot::Systemd => Duration::from_secs(15), StaticProviderSlot::PythonProcesses => Duration::from_secs(10), StaticProviderSlot::NativeProcesses => Duration::from_secs(10), @@ -180,6 +182,17 @@ fn collect_provider_slot( }, ); } + StaticProviderSlot::Cron => { + collect_cron_runtime_provider(pid_namespace, &mut collection); + collection.set_state( + slot, + if pid_namespace.is_restricted() { + ProviderStateKind::Disabled + } else { + ProviderStateKind::Fresh + }, + ); + } StaticProviderSlot::Systemd => { collect_systemd_runtime_provider(pid_namespace, &mut collection); collection.set_state( @@ -270,10 +283,6 @@ pub(crate) fn collect_host_scoped_runtime_providers( RuntimeProviderKind::Network, "Network listener discovery omitted because the daemon runs in a restricted PID namespace", ), - ( - RuntimeProviderKind::ScheduledJob, - "Scheduled job discovery omitted because the daemon runs in a restricted PID namespace", - ), ( RuntimeProviderKind::Pm2, "PM2 discovery omitted because the daemon runs in a restricted PID namespace", @@ -294,11 +303,29 @@ pub(crate) fn collect_host_scoped_runtime_providers( let (nodes, _, diagnostics) = collection.parts_mut(); collect_network_listeners(nodes, diagnostics); - collect_scheduled_jobs(nodes, diagnostics); collect_pm2_apps(nodes, diagnostics); collect_tmux_sessions(nodes, diagnostics); } +/// Cron is independently scheduled so declaration evidence has its own +/// revision, freshness, timeout and single-flight guard. It reuses the +/// existing fixed read-only command and bounded fixed filesystem roots. +fn collect_cron_runtime_provider( + pid_namespace: PidNamespaceScope, + collection: &mut ProviderCollection, +) { + if pid_namespace.is_restricted() { + collection.push_diagnostic(ProviderDiagnostic::new( + RuntimeProviderKind::ScheduledJob, + DiagnosticSeverity::Info, + "Scheduled job discovery omitted because the daemon runs in a restricted PID namespace", + )); + return; + } + let (nodes, edges, diagnostics) = collection.parts_mut(); + collect_scheduled_jobs(nodes, edges, diagnostics); +} + /// systemd's unit graph is independently scheduled so its relationship facts /// have their own state and revision. This does not add a command: it keeps /// the existing fixed, read-only `systemctl` collector and its diagnostics. @@ -363,7 +390,6 @@ mod tests { assert!(edges.is_empty()); for provider in [ RuntimeProviderKind::Network, - RuntimeProviderKind::ScheduledJob, RuntimeProviderKind::Pm2, RuntimeProviderKind::Tmux, ] { @@ -373,6 +399,22 @@ mod tests { } } + #[test] + fn restricted_namespace_keeps_cron_as_a_distinct_disabled_slot() { + let mut cron = ProviderCollection::default(); + collect_cron_runtime_provider(PidNamespaceScope::Restricted, &mut cron); + cron.set_state(StaticProviderSlot::Cron, ProviderStateKind::Disabled); + assert!(cron.states().iter().any(|state| { + state.slot == StaticProviderSlot::Cron && state.state == ProviderStateKind::Disabled + })); + let (nodes, edges, diagnostics) = cron.into_parts(); + assert!(nodes.is_empty() && edges.is_empty()); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.provider == RuntimeProviderKind::ScheduledJob + && diagnostic.message.contains("restricted PID namespace") + })); + } + #[test] fn restricted_namespace_keeps_systemd_as_a_distinct_disabled_slot() { let mut host = ProviderCollection::default(); @@ -418,6 +460,7 @@ mod tests { [ StaticProviderSlot::NetworkInfrastructure, StaticProviderSlot::HostScoped, + StaticProviderSlot::Cron, StaticProviderSlot::Systemd, StaticProviderSlot::PythonProcesses, StaticProviderSlot::NativeProcesses, diff --git a/packages/contracts/generated/rust/findings-response.schema.json b/packages/contracts/generated/rust/findings-response.schema.json index 59fae660..6c594816 100644 --- a/packages/contracts/generated/rust/findings-response.schema.json +++ b/packages/contracts/generated/rust/findings-response.schema.json @@ -82,6 +82,11 @@ ], "type": "string" }, + { + "const": "cron", + "description": "Cron has an independent collector lifecycle. It must not inherit\nhost-node, listener, PM2, or tmux freshness.", + "type": "string" + }, { "const": "systemd", "description": "systemd has an independent collector lifecycle. It must not inherit\nfreshness from the broader host-scoped observation slot.", @@ -145,6 +150,11 @@ "const": "npm_package_manifest_dependency", "description": "A package.json dependency declaration. This is not proof that the\npackage was installed, resolved, executed, or is safe.", "type": "string" + }, + { + "const": "cron_schedule_declaration", + "description": "A parsed cron declaration. This does not claim the command ran.", + "type": "string" } ] }, @@ -153,7 +163,8 @@ "enum": [ "docker", "systemd", - "npm" + "npm", + "cron" ], "type": "string" }, @@ -214,7 +225,7 @@ "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 3, + "maximum": 4, "minimum": 1, "type": "integer" } diff --git a/packages/contracts/generated/rust/runtime-map.schema.json b/packages/contracts/generated/rust/runtime-map.schema.json index bc318e9f..d1f06993 100644 --- a/packages/contracts/generated/rust/runtime-map.schema.json +++ b/packages/contracts/generated/rust/runtime-map.schema.json @@ -22,6 +22,11 @@ ], "type": "string" }, + { + "const": "cron", + "description": "Cron has an independent collector lifecycle. It must not inherit\nhost-node, listener, PM2, or tmux freshness.", + "type": "string" + }, { "const": "systemd", "description": "systemd has an independent collector lifecycle. It must not inherit\nfreshness from the broader host-scoped observation slot.", @@ -224,6 +229,11 @@ "const": "npm_package_manifest_dependency", "description": "A package.json dependency declaration. This is not proof that the\npackage was installed, resolved, executed, or is safe.", "type": "string" + }, + { + "const": "cron_schedule_declaration", + "description": "A parsed cron declaration. This does not claim the command ran.", + "type": "string" } ] }, @@ -232,7 +242,8 @@ "enum": [ "docker", "systemd", - "npm" + "npm", + "cron" ], "type": "string" }, @@ -293,7 +304,7 @@ "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 3, + "maximum": 4, "minimum": 1, "type": "integer" } @@ -966,8 +977,8 @@ "items": { "$ref": "#/$defs/ProviderState" }, - "maxItems": 6, - "minItems": 6, + "maxItems": 7, + "minItems": 7, "type": "array" }, "source": { diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index a4fee780..4daf1506 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -61,19 +61,22 @@ export type RuntimeEvidenceKind = | 'systemd_requires' | 'systemd_wants' | 'systemd_part_of' - | 'npm_package_manifest_dependency'; + | 'npm_package_manifest_dependency' + | 'cron_schedule_declaration'; /** * Evidence providers are deliberately closed. Version two adds systemd only * after it received its own scheduler slot; it cannot inherit a broader host * collection's freshness or revision. */ -export type RuntimeEvidenceProvider = 'docker' | 'systemd' | 'npm'; +export type RuntimeEvidenceProvider = 'docker' | 'systemd' | 'npm' | 'cron'; /** * Fixed, schema-backed host-provider slots. This is not a plugin or policy * interface: the daemon owns the complete finite list. */ export type ProviderSlot = - ('network_infrastructure' | 'host_scoped' | 'python_processes' | 'native_processes' | 'project_npm') | 'systemd'; + | ('network_infrastructure' | 'host_scoped' | 'python_processes' | 'native_processes' | 'project_npm') + | 'cron' + | 'systemd'; export type RuntimeRelationshipKind = | 'connected_to' | 'depends_on' @@ -237,10 +240,18 @@ export interface RuntimeMap { modelRevision: string; nodes: RuntimeMapNode[]; /** - * @minItems 6 - * @maxItems 6 + * @minItems 7 + * @maxItems 7 */ - providerStates: [ProviderState, ProviderState, ProviderState, ProviderState, ProviderState, ProviderState]; + providerStates: [ + ProviderState, + ProviderState, + ProviderState, + ProviderState, + ProviderState, + ProviderState, + ProviderState + ]; /** * ACTUAL source of these bytes: "docker" or "mock" (#85 A3). Stamped by * the daemon route layer from the cache's runtime mode. diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index f1255857..0eac7075 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -350,6 +350,11 @@ export const RUST_RESPONSE_SCHEMAS = { ], "type": "string" }, + { + "const": "cron", + "description": "Cron has an independent collector lifecycle. It must not inherit\nhost-node, listener, PM2, or tmux freshness.", + "type": "string" + }, { "const": "systemd", "description": "systemd has an independent collector lifecycle. It must not inherit\nfreshness from the broader host-scoped observation slot.", @@ -552,6 +557,11 @@ export const RUST_RESPONSE_SCHEMAS = { "const": "npm_package_manifest_dependency", "description": "A package.json dependency declaration. This is not proof that the\npackage was installed, resolved, executed, or is safe.", "type": "string" + }, + { + "const": "cron_schedule_declaration", + "description": "A parsed cron declaration. This does not claim the command ran.", + "type": "string" } ] }, @@ -560,7 +570,8 @@ export const RUST_RESPONSE_SCHEMAS = { "enum": [ "docker", "systemd", - "npm" + "npm", + "cron" ], "type": "string" }, @@ -621,7 +632,7 @@ export const RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 3, + "maximum": 4, "minimum": 1, "type": "integer" } @@ -1294,8 +1305,8 @@ export const RUST_RESPONSE_SCHEMAS = { "items": { "$ref": "#/$defs/ProviderState" }, - "maxItems": 6, - "minItems": 6, + "maxItems": 7, + "minItems": 7, "type": "array" }, "source": { @@ -1405,6 +1416,11 @@ export const RUST_RESPONSE_SCHEMAS = { ], "type": "string" }, + { + "const": "cron", + "description": "Cron has an independent collector lifecycle. It must not inherit\nhost-node, listener, PM2, or tmux freshness.", + "type": "string" + }, { "const": "systemd", "description": "systemd has an independent collector lifecycle. It must not inherit\nfreshness from the broader host-scoped observation slot.", @@ -1468,6 +1484,11 @@ export const RUST_RESPONSE_SCHEMAS = { "const": "npm_package_manifest_dependency", "description": "A package.json dependency declaration. This is not proof that the\npackage was installed, resolved, executed, or is safe.", "type": "string" + }, + { + "const": "cron_schedule_declaration", + "description": "A parsed cron declaration. This does not claim the command ran.", + "type": "string" } ] }, @@ -1476,7 +1497,8 @@ export const RUST_RESPONSE_SCHEMAS = { "enum": [ "docker", "systemd", - "npm" + "npm", + "cron" ], "type": "string" }, @@ -1537,7 +1559,7 @@ export const RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 3, + "maximum": 4, "minimum": 1, "type": "integer" } @@ -2874,6 +2896,11 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { ], "type": "string" }, + { + "const": "cron", + "description": "Cron has an independent collector lifecycle. It must not inherit\nhost-node, listener, PM2, or tmux freshness.", + "type": "string" + }, { "const": "systemd", "description": "systemd has an independent collector lifecycle. It must not inherit\nfreshness from the broader host-scoped observation slot.", @@ -3076,6 +3103,11 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "const": "npm_package_manifest_dependency", "description": "A package.json dependency declaration. This is not proof that the\npackage was installed, resolved, executed, or is safe.", "type": "string" + }, + { + "const": "cron_schedule_declaration", + "description": "A parsed cron declaration. This does not claim the command ran.", + "type": "string" } ] }, @@ -3084,7 +3116,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "enum": [ "docker", "systemd", - "npm" + "npm", + "cron" ], "type": "string" }, @@ -3145,7 +3178,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 3, + "maximum": 4, "minimum": 1, "type": "integer" } @@ -3818,8 +3851,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "items": { "$ref": "#/components/schemas/RuntimeMap/$defs/ProviderState" }, - "maxItems": 6, - "minItems": 6, + "maxItems": 7, + "minItems": 7, "type": "array" }, "source": { @@ -3929,6 +3962,11 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { ], "type": "string" }, + { + "const": "cron", + "description": "Cron has an independent collector lifecycle. It must not inherit\nhost-node, listener, PM2, or tmux freshness.", + "type": "string" + }, { "const": "systemd", "description": "systemd has an independent collector lifecycle. It must not inherit\nfreshness from the broader host-scoped observation slot.", @@ -3992,6 +4030,11 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "const": "npm_package_manifest_dependency", "description": "A package.json dependency declaration. This is not proof that the\npackage was installed, resolved, executed, or is safe.", "type": "string" + }, + { + "const": "cron_schedule_declaration", + "description": "A parsed cron declaration. This does not claim the command ran.", + "type": "string" } ] }, @@ -4000,7 +4043,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "enum": [ "docker", "systemd", - "npm" + "npm", + "cron" ], "type": "string" }, @@ -4061,7 +4105,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 3, + "maximum": 4, "minimum": 1, "type": "integer" } diff --git a/tests/fixtures/contracts/runtime-map-daemon-emitted.json b/tests/fixtures/contracts/runtime-map-daemon-emitted.json index 2777f93e..71f411bd 100644 --- a/tests/fixtures/contracts/runtime-map-daemon-emitted.json +++ b/tests/fixtures/contracts/runtime-map-daemon-emitted.json @@ -615,6 +615,16 @@ "dataRevision": "fixture-provider-2", "statusReason": null }, + { + "slot": "cron", + "state": "fresh", + "lastAttemptMs": 1787196125700, + "lastSuccessMs": 1787196125710, + "lastDurationMs": 10, + "consecutiveFailureCount": 0, + "dataRevision": "fixture-provider-cron", + "statusReason": null + }, { "slot": "systemd", "state": "fresh", diff --git a/tests/fixtures/contracts/runtime-map-expanded.json b/tests/fixtures/contracts/runtime-map-expanded.json index b145caf2..f5ba4a2a 100644 --- a/tests/fixtures/contracts/runtime-map-expanded.json +++ b/tests/fixtures/contracts/runtime-map-expanded.json @@ -604,6 +604,7 @@ "providerStates": [ { "slot": "network_infrastructure", "state": "fresh", "lastAttemptMs": 1710000001200, "lastSuccessMs": 1710000001230, "lastDurationMs": 30, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-1", "statusReason": null }, { "slot": "host_scoped", "state": "fresh", "lastAttemptMs": 1710000001200, "lastSuccessMs": 1710000001230, "lastDurationMs": 30, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-2", "statusReason": null }, + { "slot": "cron", "state": "fresh", "lastAttemptMs": 1710000001200, "lastSuccessMs": 1710000001230, "lastDurationMs": 30, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-cron", "statusReason": null }, { "slot": "systemd", "state": "fresh", "lastAttemptMs": 1710000001200, "lastSuccessMs": 1710000001230, "lastDurationMs": 30, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-systemd", "statusReason": null }, { "slot": "python_processes", "state": "fresh", "lastAttemptMs": 1710000001200, "lastSuccessMs": 1710000001230, "lastDurationMs": 30, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-3", "statusReason": null }, { "slot": "native_processes", "state": "fresh", "lastAttemptMs": 1710000001200, "lastSuccessMs": 1710000001230, "lastDurationMs": 30, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-4", "statusReason": null }, diff --git a/tests/fixtures/contracts/runtime-map.json b/tests/fixtures/contracts/runtime-map.json index 3087fa9a..259d6fee 100644 --- a/tests/fixtures/contracts/runtime-map.json +++ b/tests/fixtures/contracts/runtime-map.json @@ -28,6 +28,7 @@ "providerStates": [ { "slot": "network_infrastructure", "state": "fresh", "lastAttemptMs": 1710000000000, "lastSuccessMs": 1710000000001, "lastDurationMs": 1, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-1", "statusReason": null }, { "slot": "host_scoped", "state": "fresh", "lastAttemptMs": 1710000000000, "lastSuccessMs": 1710000000001, "lastDurationMs": 1, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-2", "statusReason": null }, + { "slot": "cron", "state": "fresh", "lastAttemptMs": 1710000000000, "lastSuccessMs": 1710000000001, "lastDurationMs": 1, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-cron", "statusReason": null }, { "slot": "systemd", "state": "fresh", "lastAttemptMs": 1710000000000, "lastSuccessMs": 1710000000001, "lastDurationMs": 1, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-systemd", "statusReason": null }, { "slot": "python_processes", "state": "fresh", "lastAttemptMs": 1710000000000, "lastSuccessMs": 1710000000001, "lastDurationMs": 1, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-3", "statusReason": null }, { "slot": "native_processes", "state": "fresh", "lastAttemptMs": 1710000000000, "lastSuccessMs": 1710000000001, "lastDurationMs": 1, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-4", "statusReason": null },