Skip to main content

trellis_core/
audit.rs

1use crate::{
2    AuditEvent, AuditExplanationLevel, Graph, GraphError, GraphResult, NodeChangeExplanation,
3    NodeHandle, NodeId, OutputFrameExplanation, OutputFrameKindTrace, OutputKey,
4    ResourceCommandCause, ResourceCommandExplanation, ResourceCommandKind, ResourceKey, ScopeId,
5    ScopeResourceInventory, TransactionResult,
6};
7use std::collections::{BTreeMap, BTreeSet, VecDeque};
8
9impl<C> Graph<C> {
10    /// Explains why a typed node last changed.
11    pub fn why_changed<H: NodeHandle>(&self, node: H) -> Option<&NodeChangeExplanation> {
12        self.why_changed_by_id(node.id())
13    }
14
15    /// Explains why a node id last changed.
16    pub fn why_changed_by_id(&self, node: NodeId) -> Option<&NodeChangeExplanation> {
17        self.audit.node_changes.get(&node)
18    }
19
20    /// Explains the latest resource command for a resource key.
21    pub fn why_resource_command(&self, key: &ResourceKey) -> Option<&ResourceCommandExplanation> {
22        self.audit.resource_commands.get(key)
23    }
24
25    /// Explains the latest output frame for an output key.
26    pub fn why_output_frame(&self, key: OutputKey) -> Option<&OutputFrameExplanation> {
27        self.audit.output_frames.get(&key)
28    }
29
30    /// Returns a dependency path from an upstream node to a downstream node.
31    pub fn dependency_path(&self, from: NodeId, to: NodeId) -> Option<Vec<NodeId>> {
32        if !self.nodes.contains_key(&from) || !self.nodes.contains_key(&to) {
33            return None;
34        }
35        let downstream = self.reverse_dependency_index();
36        shortest_dependency_path(&downstream, from, to)
37    }
38
39    /// Returns resources currently owned by a scope.
40    pub fn scope_resource_inventory(&self, scope: ScopeId) -> GraphResult<ScopeResourceInventory> {
41        self.scope_meta(scope)
42            .ok_or(GraphError::UnknownScope(scope))?;
43        let resources = self
44            .resource_owners
45            .iter()
46            .filter(|(_, owners)| owners.contains(&scope))
47            .map(|(key, _)| key.clone())
48            .collect();
49        Ok(ScopeResourceInventory { scope, resources })
50    }
51
52    pub(crate) fn record_transaction_audit(
53        &mut self,
54        result: &TransactionResult<C>,
55        level: AuditExplanationLevel,
56    ) {
57        let causes = std::mem::take(&mut self.audit.pending_resource_causes);
58        if level == AuditExplanationLevel::Disabled {
59            self.audit.clear_explanations();
60            return;
61        }
62
63        let changed_inputs = result.changed_inputs.clone();
64        let changed_nodes = changed_nodes(result);
65        let downstream = (level == AuditExplanationLevel::DependencyPaths)
66            .then(|| self.reverse_dependency_index());
67        for entry in &result.audit_log {
68            if let Some(node) = event_node(&entry.event) {
69                let dependency_paths =
70                    paths_from_inputs_to_targets(downstream.as_ref(), &changed_inputs, &[node]);
71                let input_causes = input_causes_from_paths(&dependency_paths);
72                self.audit.node_changes.insert(
73                    node,
74                    NodeChangeExplanation {
75                        node,
76                        transaction_id: entry.transaction_id,
77                        revision: entry.revision,
78                        event: entry.event.clone(),
79                        input_causes,
80                        dependency_paths,
81                    },
82                );
83            }
84        }
85
86        for (index, command) in result.resource_plan.commands().iter().enumerate() {
87            let cause = causes
88                .get(index)
89                .copied()
90                .expect("resource command cause recorded during reconciliation");
91            let collection_diffs = cause.collection().into_iter().collect::<Vec<_>>();
92            let dependency_paths = paths_from_inputs_to_targets(
93                downstream.as_ref(),
94                &changed_inputs,
95                &collection_diffs,
96            );
97            let input_causes = input_causes_from_paths(&dependency_paths);
98            self.audit.resource_commands.insert(
99                command.key().clone(),
100                ResourceCommandExplanation {
101                    key: command.key().clone(),
102                    scope: command.scope(),
103                    transaction_id: result.transaction_id,
104                    revision: result.revision,
105                    kind: ResourceCommandKind::from_command(command),
106                    cause,
107                    collection_diffs,
108                    changed_nodes: changed_nodes.clone(),
109                    input_causes,
110                    dependency_paths,
111                },
112            );
113        }
114
115        for frame in &result.output_frames {
116            let dependencies = self
117                .output_meta(frame.output_key)
118                .map(|meta| meta.dependencies().as_slice().to_vec())
119                .unwrap_or_default();
120            let changed_dependencies = dependencies
121                .iter()
122                .copied()
123                .filter(|node| changed_nodes.contains(node))
124                .collect::<Vec<_>>();
125            let dependency_paths = paths_from_inputs_to_targets(
126                downstream.as_ref(),
127                &changed_inputs,
128                &changed_dependencies,
129            );
130            let input_causes = input_causes_from_paths(&dependency_paths);
131            self.audit.output_frames.insert(
132                frame.output_key,
133                OutputFrameExplanation {
134                    output_key: frame.output_key,
135                    scope: frame.scope,
136                    transaction_id: frame.transaction_id,
137                    revision: frame.revision,
138                    kind: OutputFrameKindTrace::from_kind(&frame.kind),
139                    dependencies,
140                    changed_dependencies,
141                    input_causes,
142                    dependency_paths,
143                },
144            );
145        }
146    }
147
148    fn reverse_dependency_index(&self) -> BTreeMap<NodeId, Vec<NodeId>> {
149        let mut downstream: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
150        for meta in self.nodes.values() {
151            for dependency in meta.dependencies().as_slice() {
152                downstream.entry(*dependency).or_default().push(meta.id());
153            }
154        }
155        for nodes in downstream.values_mut() {
156            nodes.sort();
157        }
158        downstream
159    }
160}
161
162fn paths_from_inputs_to_targets(
163    downstream: Option<&BTreeMap<NodeId, Vec<NodeId>>>,
164    inputs: &[NodeId],
165    targets: &[NodeId],
166) -> Vec<Vec<NodeId>> {
167    let Some(downstream) = downstream else {
168        return Vec::new();
169    };
170
171    inputs
172        .iter()
173        .flat_map(|input| {
174            targets
175                .iter()
176                .filter_map(|target| shortest_dependency_path(downstream, *input, *target))
177        })
178        .collect()
179}
180
181fn shortest_dependency_path(
182    downstream: &BTreeMap<NodeId, Vec<NodeId>>,
183    from: NodeId,
184    to: NodeId,
185) -> Option<Vec<NodeId>> {
186    if from == to {
187        return Some(vec![from]);
188    }
189
190    let mut queue = VecDeque::from([from]);
191    let mut visited = BTreeSet::from([from]);
192    let mut previous = BTreeMap::new();
193
194    while let Some(current) = queue.pop_front() {
195        for next in downstream.get(&current).into_iter().flatten().copied() {
196            if !visited.insert(next) {
197                continue;
198            }
199            previous.insert(next, current);
200            if next == to {
201                return Some(reconstruct_path(from, to, &previous));
202            }
203            queue.push_back(next);
204        }
205    }
206
207    None
208}
209
210fn reconstruct_path(from: NodeId, to: NodeId, previous: &BTreeMap<NodeId, NodeId>) -> Vec<NodeId> {
211    let mut path = vec![to];
212    let mut current = to;
213    while current != from {
214        current = previous[&current];
215        path.push(current);
216    }
217    path.reverse();
218    path
219}
220
221impl ResourceCommandCause {
222    fn collection(self) -> Option<NodeId> {
223        match self {
224            Self::Planner { collection } => Some(collection),
225            Self::ScopeClosed { .. } => None,
226        }
227    }
228}
229
230fn input_causes_from_paths(paths: &[Vec<NodeId>]) -> Vec<NodeId> {
231    let mut causes = Vec::new();
232    for path in paths {
233        if let Some(input) = path.first()
234            && !causes.contains(input)
235        {
236            causes.push(*input);
237        }
238    }
239    causes
240}
241
242fn changed_nodes<C>(result: &TransactionResult<C>) -> Vec<NodeId> {
243    let mut nodes = result.changed_inputs.clone();
244    nodes.extend(result.changed_derived_nodes.iter().copied());
245    nodes.extend(result.changed_collection_nodes.iter().copied());
246    nodes
247}
248
249fn event_node(event: &AuditEvent) -> Option<NodeId> {
250    match event {
251        AuditEvent::InputChanged(node)
252        | AuditEvent::DerivedChanged(node)
253        | AuditEvent::CollectionChanged(node)
254        | AuditEvent::NodeCreated(node) => Some(*node),
255        AuditEvent::NodeAttached { node, .. } => Some(*node),
256        AuditEvent::InputUnchanged(_)
257        | AuditEvent::ScopeCreated(_)
258        | AuditEvent::ScopeClosed(_)
259        | AuditEvent::ResourceOpenCoalesced { .. } => None,
260    }
261}