Skip to main content

monitord/
machines.rs

1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::sync::{Arc, OnceLock};
4use std::time::Duration;
5
6use futures_util::stream::TryStreamExt;
7use thiserror::Error;
8use tokio::sync::{Mutex, RwLock};
9use tracing::{debug, error, warn};
10
11use crate::varlink::endpoint::VarlinkEndpoint;
12use crate::varlink::machine::{ListOutput, Machine, MACHINE_SOCKET_PATH};
13use crate::varlink::machine_connector::{self, MachineConnector, MachineSocket};
14use crate::MachineStats;
15use crate::MonitordStats;
16
17/// A machine's varlink connector, or why it could not be spawned. Errors are
18/// cached too, so a machine costs at most one spawn attempt per leader PID.
19type ConnectorSlot = Result<Arc<MachineConnector>, Arc<anyhow::Error>>;
20
21/// Everything cached for one machine, valid while its leader PID is unchanged.
22pub struct MachineConnection {
23    leader_pid: u32,
24    dbus: zbus::Connection,
25    /// Spawned on first varlink use, see [`machine_varlink_connector`].
26    varlink: Option<ConnectorSlot>,
27}
28
29/// Cached per-machine connections, keyed by machine name.
30/// An entry is only reused if the machine's current leader PID matches.
31pub type MachineConnections = HashMap<String, MachineConnection>;
32
33/// Set once joining machine PID namespaces failed for lack of privileges.
34/// That won't change at runtime, so from then on machines are collected over
35/// D-Bus without retrying (or logging) for every machine and cycle.
36static MACHINE_VARLINK_UNAVAILABLE: OnceLock<Arc<anyhow::Error>> = OnceLock::new();
37
38/// What action to take for a machine's cached connection.
39#[derive(Debug, PartialEq)]
40enum CacheAction {
41    /// Cached connection exists with matching leader PID — reuse it.
42    Reuse,
43    /// Cached connection exists but leader PID changed — drop old and create new.
44    Replace,
45    /// No cached connection — create new.
46    Create,
47}
48
49#[derive(Error, Debug)]
50pub enum MonitordMachinesError {
51    #[error("Machines D-Bus error: {0}")]
52    ZbusError(#[from] zbus::Error),
53}
54
55/// Whether a machine is collected: containers only, honoring the
56/// allowlist/blocklist. Shared by the varlink and D-Bus enumerations.
57fn keep_machine(
58    name: &str,
59    class: &str,
60    allowlist: &HashSet<String>,
61    blocklist: &HashSet<String>,
62) -> bool {
63    class == "container"
64        && !blocklist.contains(name)
65        && (allowlist.is_empty() || allowlist.contains(name))
66}
67
68pub fn filter_machines(
69    machines: Vec<crate::dbus::zbus_machines::ListedMachine>,
70    allowlist: &HashSet<String>,
71    blocklist: &HashSet<String>,
72) -> Vec<crate::dbus::zbus_machines::ListedMachine> {
73    machines
74        .into_iter()
75        .filter(|c| keep_machine(&c.name, &c.class, allowlist, blocklist))
76        .collect()
77}
78
79/// Enumerate machines and their leader PIDs, over machined's
80/// `io.systemd.Machine.List` varlink API (systemd v257+) when enabled,
81/// falling back to machined's D-Bus API. Records the transport used in the
82/// host's `varlink_usage.machines` gauge.
83pub async fn get_machines(
84    config: &crate::config::Config,
85    dbus_cell: &crate::DbusCell,
86    host_stats: &RwLock<MachineStats>,
87) -> anyhow::Result<HashMap<String, u32>> {
88    if config.use_varlink(&[config.machines.varlink]) {
89        let endpoint = VarlinkEndpoint::from(MACHINE_SOCKET_PATH);
90        match get_machines_varlink(endpoint, config).await {
91            Ok(machines) => {
92                host_stats.write().await.varlink_usage.machines =
93                    Some(crate::CollectorTransport::Varlink);
94                return Ok(machines);
95            }
96            Err(err) => {
97                crate::varlink_fallback::report_varlink_failure(
98                    config.varlink.no_fallback,
99                    "machines",
100                    "D-Bus",
101                    err,
102                )?;
103            }
104        }
105    }
106    // Set before the call (the lib.rs ordering): if the D-Bus enumeration
107    // errors, the gauge still says D-Bus rather than going stale or absent.
108    host_stats.write().await.varlink_usage.machines = Some(crate::CollectorTransport::Dbus);
109    let connection = crate::dbus_connection(dbus_cell, config.monitord.dbus_timeout).await?;
110    Ok(get_machines_dbus(&connection, config).await?)
111}
112
113/// Enumerate machines via `io.systemd.Machine.List`.
114///
115/// Runs on its own current-thread runtime like the streaming
116/// `io.systemd.Metrics.List` call in `varlink_units`: the zlink reply stream
117/// is `!Send`, so it cannot be held across an await on the main runtime.
118async fn get_machines_varlink(
119    endpoint: VarlinkEndpoint,
120    config: &crate::config::Config,
121) -> anyhow::Result<HashMap<String, u32>> {
122    let listed = tokio::task::spawn_blocking(move || {
123        let rt = tokio::runtime::Builder::new_current_thread()
124            .enable_all()
125            .build()?;
126        rt.block_on(async move {
127            let mut conn = endpoint.connect().await?;
128            let stream = conn.list().await?;
129            futures_util::pin_mut!(stream);
130            let mut listed = Vec::new();
131            while let Some(reply) = stream.try_next().await? {
132                listed.push(
133                    reply.map_err(|e| anyhow::anyhow!("io.systemd.Machine.List error: {}", e))?,
134                );
135            }
136            anyhow::Ok(listed)
137        })
138    })
139    .await??;
140    Ok(filter_listed_machines(
141        listed,
142        &config.machines.allowlist,
143        &config.machines.blocklist,
144    ))
145}
146
147/// Filter `io.systemd.Machine.List` replies into machine name -> leader PID.
148fn filter_listed_machines(
149    listed: Vec<ListOutput>,
150    allowlist: &HashSet<String>,
151    blocklist: &HashSet<String>,
152) -> HashMap<String, u32> {
153    listed
154        .into_iter()
155        .filter(|m| keep_machine(&m.name, &m.class, allowlist, blocklist))
156        .filter_map(|m| match m.leader.and_then(|leader| leader.pid) {
157            Some(pid) => Some((m.name, pid)),
158            None => {
159                warn!("Machine {} has no leader PID, skipping", m.name);
160                None
161            }
162        })
163        .collect()
164}
165
166async fn get_machines_dbus(
167    connection: &zbus::Connection,
168    config: &crate::config::Config,
169) -> Result<HashMap<String, u32>, MonitordMachinesError> {
170    let c = crate::dbus::zbus_machines::ManagerProxy::builder(connection)
171        .cache_properties(zbus::proxy::CacheProperties::No)
172        .build()
173        .await?;
174    let mut results = HashMap::<String, u32>::new();
175
176    let machines = c.list_machines().await?;
177
178    for machine in filter_machines(
179        machines,
180        &config.machines.allowlist,
181        &config.machines.blocklist,
182    ) {
183        let m = c.get_machine(&machine.name).await?;
184        let leader_pid = m.leader().await?;
185        results.insert(machine.name, leader_pid);
186    }
187
188    Ok(results)
189}
190
191/// Determine the cache action for a machine based on its cached and current leader PID.
192fn decide_cache_action(cached_pid: Option<u32>, leader_pid: u32) -> CacheAction {
193    match cached_pid {
194        Some(pid) if pid == leader_pid => CacheAction::Reuse,
195        Some(_) => CacheAction::Replace,
196        None => CacheAction::Create,
197    }
198}
199
200/// Remove cached connections for machines that no longer exist.
201async fn evict_stale_connections(
202    cached_connections: &Mutex<MachineConnections>,
203    current_machines: &HashMap<String, u32>,
204) {
205    let mut cache = cached_connections.lock().await;
206    cache.retain(|name, _| current_machines.contains_key(name));
207}
208
209/// Evict a cached connection for a machine that experienced errors.
210async fn evict_failed_connection(cached_connections: &Mutex<MachineConnections>, machine: &str) {
211    debug!("Evicting cached connections for {} due to errors", machine);
212    let mut cache = cached_connections.lock().await;
213    cache.remove(machine);
214}
215
216/// Return a cached D-Bus connection if one exists for the same leader PID,
217/// otherwise create a new connection to the container's system bus.
218async fn get_or_create_connection(
219    config: &crate::config::Config,
220    cached_connections: &Mutex<MachineConnections>,
221    machine: &str,
222    leader_pid: u32,
223) -> anyhow::Result<zbus::Connection> {
224    // Check cache and return if hit; drop the lock before any async work
225    {
226        let mut cache = cached_connections.lock().await;
227        match decide_cache_action(cache.get(machine).map(|c| c.leader_pid), leader_pid) {
228            CacheAction::Reuse => {
229                debug!("Reusing cached D-Bus connection for {}", machine);
230                return Ok(cache[machine].dbus.clone());
231            }
232            CacheAction::Replace => {
233                debug!(
234                    "Leader PID changed for {}, dropping stale connections",
235                    machine
236                );
237                cache.remove(machine);
238            }
239            CacheAction::Create => {}
240        }
241    }
242
243    // Build connection without holding the lock
244    debug!("Creating new D-Bus connection for {}", machine);
245    let container_address = format!(
246        "unix:path=/proc/{}/root/run/dbus/system_bus_socket",
247        leader_pid
248    );
249    let conn = zbus::connection::Builder::address(container_address.as_str())
250        .method_timeout(std::time::Duration::from_secs(config.monitord.dbus_timeout))
251        .build()
252        .await?;
253
254    // Re-lock to insert
255    {
256        let mut cache = cached_connections.lock().await;
257        cache.insert(
258            machine.to_string(),
259            MachineConnection {
260                leader_pid,
261                dbus: conn.clone(),
262                varlink: None,
263            },
264        );
265    }
266
267    Ok(conn)
268}
269
270/// The varlink connector for a machine, spawning it on first use.
271///
272/// `None` means don't try varlink for this machine at all: it is disabled, or
273/// monitord lacks the privileges to join machine namespaces (warned about
274/// once). With `[varlink] no_fallback` the latter is returned as an error
275/// instead, so it fails loudly rather than quietly using D-Bus.
276async fn machine_varlink_connector(
277    config: &crate::config::Config,
278    cached_connections: &Mutex<MachineConnections>,
279    machine: &str,
280    leader_pid: u32,
281) -> Option<ConnectorSlot> {
282    if !config.use_varlink(&[config.machines.varlink]) {
283        return None;
284    }
285    if let Some(err) = MACHINE_VARLINK_UNAVAILABLE.get() {
286        return config.varlink.no_fallback.then(|| Err(Arc::clone(err)));
287    }
288    {
289        let cache = cached_connections.lock().await;
290        match cache
291            .get(machine)
292            .filter(|c| c.leader_pid == leader_pid)
293            .and_then(|c| c.varlink.clone())
294        {
295            Some(Ok(connector)) if !connector.is_alive() => {
296                debug!("Varlink connector for {} exited, respawning", machine);
297            }
298            Some(slot) => return Some(slot),
299            None => {}
300        }
301    }
302
303    let timeout = Duration::from_secs(config.monitord.dbus_timeout);
304    let slot: ConnectorSlot =
305        match tokio::task::spawn_blocking(move || MachineConnector::spawn(leader_pid, timeout))
306            .await
307        {
308            Ok(Ok(connector)) => {
309                debug!(
310                    "Spawned varlink connector for machine {} (leader {})",
311                    machine, leader_pid
312                );
313                Ok(Arc::new(connector))
314            }
315            Ok(Err(err)) => Err(Arc::new(err.into())),
316            Err(err) => Err(Arc::new(err.into())),
317        };
318
319    if let Err(err) = &slot {
320        if machine_connector::is_permission_denied(err) {
321            if MACHINE_VARLINK_UNAVAILABLE.set(Arc::clone(err)).is_ok() {
322                warn!("{:#}; collecting machines over D-Bus instead", err);
323            }
324            return config.varlink.no_fallback.then_some(slot);
325        }
326    }
327
328    let mut cache = cached_connections.lock().await;
329    if let Some(entry) = cache
330        .get_mut(machine)
331        .filter(|c| c.leader_pid == leader_pid)
332    {
333        entry.varlink = Some(slot.clone());
334    }
335    Some(slot)
336}
337
338/// The endpoint for `socket` in a machine, if varlink is to be tried there.
339fn machine_endpoint(
340    connector: &Option<ConnectorSlot>,
341    socket: MachineSocket,
342) -> Option<anyhow::Result<VarlinkEndpoint>> {
343    connector.as_ref().map(|slot| match slot {
344        Ok(connector) => Ok(VarlinkEndpoint::Machine {
345            connector: Arc::clone(connector),
346            socket,
347        }),
348        Err(err) => Err(anyhow::anyhow!("{:#}", err)),
349    })
350}
351
352pub async fn update_machines_stats(
353    config: Arc<crate::config::Config>,
354    dbus_cell: crate::DbusCell,
355    host_stats: Arc<RwLock<MachineStats>>,
356    locked_monitord_stats: Arc<RwLock<MonitordStats>>,
357    cached_connections: Arc<Mutex<MachineConnections>>,
358) -> anyhow::Result<()> {
359    let locked_machine_stats: Arc<RwLock<MachineStats>> =
360        Arc::new(RwLock::new(MachineStats::default()));
361
362    let current_machines = get_machines(&config, &dbus_cell, &host_stats).await?;
363
364    evict_stale_connections(&cached_connections, &current_machines).await;
365
366    for (machine, leader_pid) in current_machines.into_iter() {
367        debug!(
368            "Collecting container: machine: {} leader_pid: {}",
369            machine, leader_pid
370        );
371
372        let sdc = match get_or_create_connection(&config, &cached_connections, &machine, leader_pid)
373            .await
374        {
375            Ok(conn) => conn,
376            Err(e) => {
377                error!("Failed to connect to container {}: {:?}", machine, e);
378                continue;
379            }
380        };
381
382        let connector =
383            machine_varlink_connector(&config, &cached_connections, &machine, leader_pid).await;
384
385        let mut join_set = tokio::task::JoinSet::new();
386
387        if config.pid1.enabled {
388            join_set.spawn(crate::pid1::update_pid1_stats(
389                leader_pid as i32,
390                locked_machine_stats.clone(),
391            ));
392        }
393
394        if config.networkd.enabled {
395            let config_clone = Arc::clone(&config);
396            let stats_clone = locked_machine_stats.clone();
397            let machine_name = machine.clone();
398            let no_fallback = config_clone.varlink.no_fallback;
399            let endpoint = config_clone
400                .use_varlink(&[config_clone.networkd.varlink])
401                .then(|| machine_endpoint(&connector, MachineSocket::Network))
402                .flatten();
403            join_set.spawn(async move {
404                if let Some(endpoint) = endpoint {
405                    let result = match endpoint {
406                        Ok(endpoint) => {
407                            crate::varlink_networkd::get_networkd_state(&endpoint).await
408                        }
409                        Err(err) => Err(err),
410                    };
411                    match result {
412                        Ok(networkd_stats) => {
413                            let mut machine_stats = stats_clone.write().await;
414                            machine_stats.networkd = networkd_stats;
415                            machine_stats.varlink_usage.networkd =
416                                Some(crate::CollectorTransport::Varlink);
417                            return Ok(());
418                        }
419                        Err(err) => {
420                            crate::varlink_fallback::report_varlink_failure(
421                                no_fallback,
422                                &format!("container {machine_name} networkd"),
423                                "file-based",
424                                err,
425                            )?;
426                        }
427                    }
428                }
429                stats_clone.write().await.varlink_usage.networkd =
430                    Some(crate::CollectorTransport::Dbus);
431                // Same fs-root prefixing the units/cgroup collectors use:
432                // both the link state files AND the sysfs ifindex map
433                // come from inside the container, so host ifindexes are
434                // never labelled with container interface names (or vice
435                // versa). A container sharing the host netns simply sees
436                // identical trees, which is why CI never caught the old
437                // host-files/host-bus pairing being self-consistent.
438                let container_root = format!("/proc/{leader_pid}/root");
439                let container_sysfs = std::path::PathBuf::from(format!("{container_root}/sys"));
440                let container_links = std::path::PathBuf::from(format!(
441                    "{container_root}{}",
442                    config_clone.networkd.link_state_dir.display()
443                ));
444                crate::networkd::update_networkd_stats(
445                    container_links,
446                    None,
447                    container_sysfs,
448                    None,
449                    stats_clone,
450                )
451                .await
452            });
453        }
454
455        // One Describe per container, shared by its version and system state
456        // collectors, against the container's PID 1 varlink socket.
457        let manager_describe = config
458            .use_varlink(&[config.system_state.varlink])
459            .then(|| machine_endpoint(&connector, MachineSocket::Manager))
460            .flatten()
461            .map(|endpoint| endpoint.map(crate::varlink_system::shared_describe));
462
463        if config.system_state.enabled {
464            let sdc_clone = sdc.clone();
465            let stats_clone = locked_machine_stats.clone();
466            let machine_name = machine.clone();
467            let no_fallback = config.varlink.no_fallback;
468            let describe = clone_describe(&manager_describe);
469            join_set.spawn(async move {
470                if let Some(describe) = describe {
471                    let result = match describe {
472                        Ok(describe) => {
473                            crate::varlink_system::update_system_stats(
474                                describe,
475                                stats_clone.clone(),
476                            )
477                            .await
478                        }
479                        Err(err) => Err(err),
480                    };
481                    match result {
482                        Ok(()) => {
483                            stats_clone.write().await.varlink_usage.system_state =
484                                Some(crate::CollectorTransport::Varlink);
485                            return Ok(());
486                        }
487                        Err(err) => {
488                            crate::varlink_fallback::report_varlink_failure(
489                                no_fallback,
490                                &format!("container {machine_name} system state"),
491                                "D-Bus",
492                                err,
493                            )?;
494                        }
495                    }
496                }
497                stats_clone.write().await.varlink_usage.system_state =
498                    Some(crate::CollectorTransport::Dbus);
499                crate::system::update_system_stats(sdc_clone, stats_clone.clone()).await
500            });
501        }
502
503        {
504            let sdc_clone = sdc.clone();
505            let stats_clone = locked_machine_stats.clone();
506            let machine_name = machine.clone();
507            let no_fallback = config.varlink.no_fallback;
508            let describe = clone_describe(&manager_describe);
509            join_set.spawn(async move {
510                if let Some(describe) = describe {
511                    let result = match describe {
512                        Ok(describe) => {
513                            crate::varlink_system::update_version(describe, stats_clone.clone())
514                                .await
515                        }
516                        Err(err) => Err(err),
517                    };
518                    match result {
519                        Ok(()) => {
520                            stats_clone.write().await.varlink_usage.version =
521                                Some(crate::CollectorTransport::Varlink);
522                            return Ok(());
523                        }
524                        Err(err) => {
525                            crate::varlink_fallback::report_varlink_failure(
526                                no_fallback,
527                                &format!("container {machine_name} version"),
528                                "D-Bus",
529                                err,
530                            )?;
531                        }
532                    }
533                }
534                stats_clone.write().await.varlink_usage.version =
535                    Some(crate::CollectorTransport::Dbus);
536                crate::system::update_version(sdc_clone, stats_clone.clone()).await
537            });
538        }
539
540        if config.units.enabled {
541            let config_clone = Arc::clone(&config);
542            let sdc_clone = sdc.clone();
543            let stats_clone = locked_machine_stats.clone();
544            let no_fallback = config_clone.varlink.no_fallback;
545            let machine_name = machine.clone();
546            let container_root = format!("/proc/{}/root", leader_pid);
547            let endpoints = config
548                .use_varlink(&[config.units.varlink])
549                .then(|| {
550                    let metrics = machine_endpoint(&connector, MachineSocket::Metrics)?;
551                    let manager = machine_endpoint(&connector, MachineSocket::Manager)?;
552                    Some(metrics.and_then(|metrics| manager.map(|manager| (metrics, manager))))
553                })
554                .flatten();
555            join_set.spawn(async move {
556                if let Some(endpoints) = endpoints {
557                    match collect_container_units_varlink(
558                        &config_clone,
559                        &stats_clone,
560                        endpoints,
561                        &container_root,
562                    )
563                    .await
564                    {
565                        Ok(()) => {
566                            stats_clone.write().await.varlink_usage.units =
567                                Some(crate::CollectorTransport::Varlink);
568                            return Ok(());
569                        }
570                        Err(err) => {
571                            crate::varlink_fallback::report_varlink_failure(
572                                no_fallback,
573                                &format!("container {machine_name} units"),
574                                "D-Bus",
575                                err,
576                            )?;
577                        }
578                    }
579                }
580                // Set before the call (the lib.rs ordering): if the D-Bus
581                // collection errors, the gauge still says D-Bus rather than
582                // going stale or absent.
583                stats_clone.write().await.varlink_usage.units =
584                    Some(crate::CollectorTransport::Dbus);
585                crate::units::update_unit_stats(
586                    config_clone,
587                    sdc_clone,
588                    stats_clone,
589                    container_root,
590                )
591                .await
592            });
593        }
594
595        if config.dbus_stats.enabled {
596            join_set.spawn(crate::dbus_stats::update_machine_dbus_stats(
597                Arc::clone(&config),
598                sdc.clone(),
599                locked_machine_stats.clone(),
600            ));
601        }
602
603        let mut had_error = false;
604        while let Some(res) = join_set.join_next().await {
605            match res {
606                Ok(r) => match r {
607                    Ok(_) => (),
608                    Err(e) => {
609                        had_error = true;
610                        error!(
611                            "Collection specific failure (container {}): {:?}",
612                            machine, e
613                        );
614                    }
615                },
616                Err(e) => {
617                    had_error = true;
618                    error!("Join error (container {}): {:?}", machine, e);
619                }
620            }
621        }
622
623        if had_error {
624            evict_failed_connection(&cached_connections, &machine).await;
625        }
626
627        {
628            let mut monitord_stats = locked_monitord_stats.write().await;
629            let machine_stats = locked_machine_stats.read().await;
630            monitord_stats
631                .machines
632                .insert(machine, machine_stats.clone());
633        }
634    }
635
636    Ok(())
637}
638
639type MachineDescribe = Option<anyhow::Result<crate::varlink_system::SharedDescribe>>;
640
641/// Clone the per-container shared `Describe` for one more collector; an
642/// error is re-rendered, since `anyhow::Error` itself is not `Clone`.
643fn clone_describe(describe: &MachineDescribe) -> MachineDescribe {
644    describe.as_ref().map(|describe| match describe {
645        Ok(describe) => Ok(describe.clone()),
646        Err(err) => Err(anyhow::anyhow!("{:#}", err)),
647    })
648}
649
650/// Collect a container's units over varlink the way the host does: unit and
651/// timer metrics from `io.systemd.Metrics`, then per-service stats, timer
652/// properties and service types from `io.systemd.Unit.List`, with cgroup and
653/// unit file data read below the container's root. Any failure redoes the
654/// whole collection over D-Bus (see the host path in `lib.rs` for why).
655async fn collect_container_units_varlink(
656    config: &Arc<crate::config::Config>,
657    stats: &Arc<RwLock<MachineStats>>,
658    endpoints: anyhow::Result<(VarlinkEndpoint, VarlinkEndpoint)>,
659    container_root: &str,
660) -> anyhow::Result<()> {
661    let (metrics, manager) = endpoints?;
662    let discovery = crate::varlink_units::update_unit_stats(
663        Arc::clone(config),
664        stats.clone(),
665        crate::varlink_units::shared_metrics(metrics),
666    )
667    .await?;
668    crate::varlink_units::apply_unit_details(&manager, stats, config, container_root, &discovery)
669        .await?;
670    if config.units.unit_files {
671        let unit_files = crate::units::collect_unit_files_stats(container_root).await;
672        stats.write().await.units.unit_files = unit_files;
673    }
674    Ok(())
675}
676
677#[cfg(test)]
678mod tests {
679    use std::collections::HashSet;
680    use zbus::OwnedObjectPath;
681
682    use super::{decide_cache_action, CacheAction};
683
684    #[test]
685    fn test_filter_machines() {
686        let machines = vec![
687            crate::dbus::zbus_machines::ListedMachine {
688                name: "foo".to_string(),
689                class: "container".to_string(),
690                service: "".to_string(),
691                path: OwnedObjectPath::try_from("/sample/object").unwrap(),
692            },
693            crate::dbus::zbus_machines::ListedMachine {
694                name: "bar".to_string(),
695                class: "container".to_string(),
696                service: "".to_string(),
697                path: OwnedObjectPath::try_from("/sample/object").unwrap(),
698            },
699            crate::dbus::zbus_machines::ListedMachine {
700                name: "baz".to_string(),
701                class: "container".to_string(),
702                service: "".to_string(),
703                path: OwnedObjectPath::try_from("/sample/object").unwrap(),
704            },
705        ];
706        let allowlist = HashSet::from(["foo".to_string(), "baz".to_string()]);
707        let blocklist = HashSet::from(["bar".to_string()]);
708
709        let filtered = super::filter_machines(machines, &allowlist, &blocklist);
710
711        assert_eq!(filtered.len(), 2);
712        assert_eq!(filtered[0].name, "foo");
713        assert_eq!(filtered[1].name, "baz");
714    }
715
716    #[test]
717    fn test_filter_listed_machines() {
718        let listed: Vec<crate::varlink::machine::ListOutput> = serde_json::from_str(
719            r#"[
720                {"name":"foo","class":"container","leader":{"pid":10}},
721                {"name":"bar","class":"container","leader":{"pid":11}},
722                {"name":"baz","class":"container","leader":{"pid":12}},
723                {"name":"vm0","class":"vm","leader":{"pid":13}},
724                {"name":".host","class":"host","leader":{"pid":1}},
725                {"name":"noleader","class":"container"}
726            ]"#,
727        )
728        .unwrap();
729        let blocklist = HashSet::from(["bar".to_string()]);
730
731        let all = super::filter_listed_machines(listed.clone(), &HashSet::new(), &blocklist);
732        assert_eq!(
733            all,
734            std::collections::HashMap::from([("foo".to_string(), 10), ("baz".to_string(), 12)])
735        );
736
737        let allowlist = HashSet::from(["baz".to_string(), "vm0".to_string()]);
738        let allowed = super::filter_listed_machines(listed, &allowlist, &blocklist);
739        assert_eq!(
740            allowed,
741            std::collections::HashMap::from([("baz".to_string(), 12)])
742        );
743    }
744
745    #[test]
746    fn test_decide_cache_action_reuse_on_same_pid() {
747        assert_eq!(decide_cache_action(Some(42), 42), CacheAction::Reuse);
748    }
749
750    #[test]
751    fn test_decide_cache_action_replace_on_pid_change() {
752        assert_eq!(decide_cache_action(Some(42), 99), CacheAction::Replace);
753    }
754
755    #[test]
756    fn test_decide_cache_action_create_on_miss() {
757        assert_eq!(decide_cache_action(None, 42), CacheAction::Create);
758    }
759}