saluki_env/workload/stores/
external_data.rs

1use std::{num::NonZeroUsize, sync::Arc};
2
3use arc_swap::ArcSwap;
4use saluki_common::collections::{FastHashSet, FastIndexMap};
5use saluki_context::origin::{ExternalData, RawExternalData};
6use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
7use saluki_metrics::{static_metrics, Counter, Gauge};
8use tracing::{debug, trace};
9
10use crate::workload::{
11    aggregator::MetadataStore, origin::ResolvedExternalData, EntityId, MetadataAction, MetadataOperation,
12};
13
14#[static_metrics(prefix = external_data_store)]
15#[derive(Clone)]
16struct Telemetry {
17    entity_limit: Gauge,
18    active_entities: Gauge,
19    ops_delete_total: Counter,
20    ops_attach_external_data_total: Counter,
21}
22
23/// A store for External Data entity mappings.
24///
25/// "External Data" is a concept that's used to aid origin detection of workloads running in Kubernetes environments
26/// where introspection isn't possible or may return incorrect information. Origin detection generally centers around
27/// determining the container where a metric originates from, and then enriching the metric with tags that describe that
28/// container, as well as the pod the container is running within, and so on. In some cases, the origin of a metric
29/// can't be detected from the outside (such as by using peer credentials over Unix Domain sockets) and can't be
30/// detected by the workload itself (such as when running in nested virtualization environments). In these cases, we
31/// need a mechanism to attach metadata to the workload such that it can send the necessary information to allow for the
32/// origin of a metric to be correctly detected.
33///
34/// "External Data" supports this by allowing for an external Kubernetes admission controller to attach specific
35/// metadata -- pod UID and container name -- to application pods, which is then read and sent along with metrics. This
36/// information is then used during origin detection in order to correlate the container ID of the origin, which is
37/// sufficient to allow enriching the metric with container-specific tags.
38///
39/// See [`ExternalData`] for more information on the External Data format itself.
40pub struct ExternalDataStore {
41    snapshot: Arc<ArcSwap<ExternalDataSnapshot>>,
42    entity_limit: NonZeroUsize,
43    active_entities: FastHashSet<EntityId>,
44    forward_mappings: FastIndexMap<ExternalData, ResolvedExternalData>,
45    reverse_mappings: FastIndexMap<EntityId, ExternalData>,
46    telemetry: Telemetry,
47}
48
49impl ExternalDataStore {
50    /// Creates a new `ExternalDataStore` with the given entity limit.
51    ///
52    /// The entity limit is the maximum number of unique entities that can be stored. Once the limit is reached, new
53    /// entities won't be added to the store.
54    pub fn with_entity_limit(entity_limit: NonZeroUsize) -> Self {
55        let telemetry = Telemetry::new();
56        telemetry.entity_limit().set(entity_limit.get() as f64);
57
58        Self {
59            snapshot: Arc::new(ArcSwap::new(Arc::new(ExternalDataSnapshot::default()))),
60            entity_limit,
61            active_entities: FastHashSet::default(),
62            forward_mappings: FastIndexMap::default(),
63            reverse_mappings: FastIndexMap::default(),
64            telemetry,
65        }
66    }
67
68    /// Returns the maximum number of unique entities that can be tracked by the store at any given time.
69    pub fn entity_limit(&self) -> usize {
70        self.entity_limit.get()
71    }
72
73    fn track_entity(&mut self, entity_id: &EntityId) -> bool {
74        if self.active_entities.contains(entity_id) {
75            return true;
76        }
77
78        if self.active_entities.len() >= self.entity_limit() {
79            return false;
80        }
81
82        self.telemetry.active_entities().increment(1);
83        let _ = self.active_entities.insert(entity_id.clone());
84        true
85    }
86
87    fn add_mapping(&mut self, external_data: ExternalData, entity_id: EntityId) {
88        if !self.track_entity(&entity_id) {
89            trace!(
90                entity_limit = self.entity_limit(),
91                %entity_id,
92                "Entity limit reached, not adding mapping."
93            );
94            return;
95        }
96
97        // We create a "resolved" form of the External Data, which includes entity IDs for both the pod and the
98        // container that this External Data is attached to.
99        let resolved = ResolvedExternalData::new(EntityId::PodUid(external_data.pod_uid().clone()), entity_id.clone());
100
101        let _ = self.forward_mappings.insert(external_data.clone(), resolved);
102        let _ = self.reverse_mappings.insert(entity_id, external_data);
103    }
104
105    fn remove_mapping(&mut self, entity_id: EntityId) {
106        if !self.active_entities.remove(&entity_id) {
107            return;
108        }
109
110        self.telemetry.active_entities().decrement(1);
111
112        if let Some(external_data) = self.reverse_mappings.swap_remove(&entity_id) {
113            let _ = self.forward_mappings.swap_remove(&external_data);
114        }
115    }
116
117    /// Returns a `ExternalDataStoreResolver` that can be used to concurrently resolve entity IDs from External Data.
118    pub fn resolver(&self) -> ExternalDataStoreResolver {
119        ExternalDataStoreResolver {
120            snapshot: Arc::clone(&self.snapshot),
121        }
122    }
123}
124
125impl MetadataStore for ExternalDataStore {
126    fn name(&self) -> &'static str {
127        "external_data"
128    }
129
130    fn process_operation(&mut self, operation: MetadataOperation) {
131        debug!(?operation, "Processing metadata operation.");
132
133        // TODO: Maybe come up with a better pattern for doing "only clone for the first N-1 actions, don't clone for the
134        // Nth" since we're needlessly cloning a lot with this current approach.
135        let entity_id = operation.entity_id;
136        for action in operation.actions {
137            match action {
138                MetadataAction::AttachExternalData { external_data } => {
139                    self.telemetry.ops_attach_external_data_total().increment(1);
140                    self.add_mapping(external_data, entity_id.clone());
141                }
142                MetadataAction::Delete => {
143                    self.telemetry.ops_delete_total().increment(1);
144                    self.remove_mapping(entity_id.clone());
145                }
146
147                // We only care about external data, and knowing when to clean up mappings.
148                _ => {}
149            }
150        }
151
152        // Update the snapshot.
153        let snapshot = Arc::new(ExternalDataSnapshot {
154            forward_mappings: self.forward_mappings.clone(),
155        });
156
157        self.snapshot.store(snapshot);
158    }
159}
160
161impl MemoryBounds for ExternalDataStore {
162    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
163        builder
164            .firm()
165            // Active entities.
166            .with_array::<EntityId>("entity ids", self.entity_limit())
167            // Forward and reverse mappings.
168            .with_map::<ExternalData, EntityId>("ext data entity map", self.entity_limit())
169            .with_map::<EntityId, ExternalData>("entity ext data map", self.entity_limit());
170    }
171}
172
173#[derive(Default)]
174struct ExternalDataSnapshot {
175    forward_mappings: FastIndexMap<ExternalData, ResolvedExternalData>,
176}
177
178/// A handle for resolving entity IDs from an `ExternalDataStore`.
179#[derive(Clone)]
180pub struct ExternalDataStoreResolver {
181    snapshot: Arc<ArcSwap<ExternalDataSnapshot>>,
182}
183
184impl ExternalDataStoreResolver {
185    /// Resolves the given raw external data.
186    ///
187    /// The given raw external data is parsed and lookups are performed to resolve the underlying entity IDs (pod and
188    /// container). If the external data maps to valid, and the referenced entities exist, `Some(ResolvedExternalData)`
189    /// is returned, containing the entity IDs for both pod and container. Otherwise, if the raw external data is
190    /// invalid, or the referenced entities don't exist, `None` is returned.
191    pub fn resolve(&self, external_data: &RawExternalData<'_>) -> Option<ResolvedExternalData> {
192        let snapshot = self.snapshot.load();
193        snapshot.forward_mappings.get(external_data).cloned()
194    }
195
196    /// Executes the given function for each forward mapping in the latest snapshot.
197    pub fn with_latest_snapshot<F>(&self, mut f: F)
198    where
199        F: FnMut(&ExternalData, &EntityId),
200    {
201        let snapshot = self.snapshot.load();
202        for (external_data, resolved_ed) in snapshot.forward_mappings.iter() {
203            f(external_data, resolved_ed.container_entity_id());
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use std::num::NonZeroUsize;
211
212    use saluki_context::origin::{ExternalData, RawExternalData};
213
214    use super::ExternalDataStore;
215    use crate::workload::{aggregator::MetadataStore as _, origin::ResolvedExternalData, EntityId, MetadataOperation};
216
217    const DEFAULT_ENTITY_LIMIT: NonZeroUsize = NonZeroUsize::new(10).unwrap();
218
219    fn entity_id_container(id: &str) -> EntityId {
220        EntityId::Container(id.into())
221    }
222
223    fn build_external_data(
224        pod_uid: &str, container_name: &str, container_id: &EntityId, init_container: bool,
225    ) -> (String, ExternalData, ResolvedExternalData) {
226        let raw_external_data = format!("pu-{},cn-{},it-{}", pod_uid, container_name, init_container);
227        let pod_entity_id = EntityId::from_pod_uid(pod_uid).unwrap();
228
229        let external_data = ExternalData::new(pod_uid.into(), container_name.into(), init_container);
230        let resolved_external_data = ResolvedExternalData::new(pod_entity_id.clone(), container_id.clone());
231        (raw_external_data, external_data, resolved_external_data)
232    }
233
234    #[test]
235    fn basic() {
236        let mut store = ExternalDataStore::with_entity_limit(DEFAULT_ENTITY_LIMIT);
237        let resolver = store.resolver();
238
239        let container_eid = entity_id_container("abcdef");
240        let (raw_ed, ed, resolved_ed) = build_external_data("1234", "redis", &container_eid, false);
241        let ed_ref = RawExternalData::try_from_str(&raw_ed).unwrap();
242
243        // Make sure we don't get anything back for this External Data yet:
244        assert_eq!(resolver.resolve(&ed_ref), None);
245
246        // Attach the External Data to the given container:
247        store.process_operation(MetadataOperation::attach_external_data(container_eid.clone(), ed));
248
249        // Now we should be able to resolve the External Data:
250        assert_eq!(resolver.resolve(&ed_ref), Some(resolved_ed));
251
252        // Delete the container entity, which should drop the attached External Data:
253        store.process_operation(MetadataOperation::delete(container_eid));
254
255        assert_eq!(resolver.resolve(&ed_ref), None);
256    }
257
258    #[test]
259    fn obeys_entity_limit() {
260        // Create our `ExternalDataStore` with a reduced entity limit of two:
261        let mut store = ExternalDataStore::with_entity_limit(NonZeroUsize::new(2).unwrap());
262        let resolver = store.resolver();
263
264        // Make sure we don't get anything back for any of this External Data yet:
265        let container_eid1 = entity_id_container("abcdef");
266        let container_eid2 = entity_id_container("bcdefg");
267        let container_eid3 = entity_id_container("cdefgh");
268        let (raw_ed1, ed1, resolved_ed1) = build_external_data("1234", "redis", &container_eid1, false);
269        let (raw_ed2, ed2, resolved_ed2) = build_external_data("1234", "init-volume", &container_eid2, true);
270        let (raw_ed3, ed3, resolved_ed3) = build_external_data("1234", "chmod-dir", &container_eid3, true);
271        let ed_ref1 = RawExternalData::try_from_str(&raw_ed1).unwrap();
272        let ed_ref2 = RawExternalData::try_from_str(&raw_ed2).unwrap();
273        let ed_ref3 = RawExternalData::try_from_str(&raw_ed3).unwrap();
274
275        assert_eq!(resolver.resolve(&ed_ref1), None);
276        assert_eq!(resolver.resolve(&ed_ref2), None);
277        assert_eq!(resolver.resolve(&ed_ref3), None);
278
279        // Attach the External Data to all of the containers:
280        store.process_operation(MetadataOperation::attach_external_data(container_eid1.clone(), ed1));
281        store.process_operation(MetadataOperation::attach_external_data(container_eid2, ed2));
282        store.process_operation(MetadataOperation::attach_external_data(
283            container_eid3.clone(),
284            ed3.clone(),
285        ));
286
287        // Now we should be able to resolve External Data for the first two container entities, but not the third, as we
288        // have hit our entity limit:
289        assert_eq!(resolver.resolve(&ed_ref1), Some(resolved_ed1));
290        assert_eq!(resolver.resolve(&ed_ref2), Some(resolved_ed2));
291        assert_eq!(resolver.resolve(&ed_ref3), None);
292
293        // Delete the first container entity, which should drop the attached External Data:
294        store.process_operation(MetadataOperation::delete(container_eid1));
295        assert_eq!(resolver.resolve(&ed_ref1), None);
296
297        // Try again to attach the External Data to the third container entity, which we should now be able to resolve:
298        store.process_operation(MetadataOperation::attach_external_data(container_eid3, ed3));
299        assert_eq!(resolver.resolve(&ed_ref3), Some(resolved_ed3));
300    }
301}