Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/lib/src/capacity.rs

//! **`AdminService.Capacity`** — how much storage this holger has, may still
//! write, and has already used, plus the shape a repository takes on the wire.
//!
//! Two things live here, and both exist because monetize (the plugin that
//! meters holger) could not get them from the wire before 2026-09-20:
//!
//! 1. [`measure`] — ONE `statvfs(3)` over the data root (the free-space source
//!    [`crate::metering::statvfs`] answers, added the same day) plus the archive
//!    bytes of every repository that OWNS storage. A group repository is a
//!    read-only VIEW whose `archive_info` is the SUM of its members'
//!    (`crate::group::GroupBackend::archive_info`); adding it to a fleet total
//!    counts its members twice, so [`used_by_holger_bytes`] skips every
//!    [`RepositoryBackendTrait::is_group`].
//!
//! 2. [`repository_info`] — the ONE mapping from a backend to the proto
//!    [`RepositoryInfo`], so `ListRepositories` and every test that asks "what
//!    does this backend look like on the wire" agree by construction.
//!
//! Neither answers a zero for a failure. A root the kernel will not describe, or
//! a repository whose archive cannot be read, is a named `Err` — the discipline
//! `metering.rs` is written around, kept here on purpose.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use traits::RepositoryBackendTrait;

use crate::grpc::holger_proto::RepositoryInfo;
use crate::metering::statvfs;

/// One measurement, in holger's own words, before it goes on the proto wire.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Capacity {
    /// The filesystem size under `path`.
    pub total_bytes: u64,
    /// SERVABLE — what holger, an unprivileged writer, may still put there.
    pub available_bytes: u64,
    /// The archive bytes of every repository that owns storage; groups excluded.
    pub used_by_holger_bytes: u64,
    /// The data root the filesystem figures were taken over.
    pub path: PathBuf,
    /// CPUs this process may run on — the election's first axis (stinky SB #37).
    pub cpu_cores: u32,
    /// The box's RAM ([`ram_bytes`]: MemTotal, capped by a cgroup limit), bytes — its second; 0 where unreadable.
    pub ram_bytes: u64,
}

/// CPUs this process may run on (`available_parallelism`); 0 where unknown.
pub fn cpu_cores() -> u32 {
    std::thread::available_parallelism()
        .map(|n| n.get() as u32)
        .unwrap_or(0)
}

/// The box's RAM, bytes: `MemTotal` of `/proc/meminfo`, or the cgroup's memory limit when one
/// is set and is lower (a container sees its HOST's MemTotal; its limit is what it really has).
/// File reads only, nothing shelled out.
pub fn ram_bytes() -> Option<u64> {
    let meminfo = std::fs::read_to_string("/proc/meminfo").unwrap_or_default();
    let limit = std::fs::read_to_string("/sys/fs/cgroup/memory.max")
        .or_else(|_| std::fs::read_to_string("/sys/fs/cgroup/memory/memory.limit_in_bytes"))
        .ok();
    ram_bytes_limited(&meminfo, limit.as_deref())
}

/// Pure: [`ram_bytes_of`] capped by a cgroup memory limit's text (`memory.max`: a number or
/// `max`; v1's `memory.limit_in_bytes`, whose "unlimited" is a huge number). A limit that is
/// not a number, or is above MemTotal, changes nothing.
pub fn ram_bytes_limited(meminfo: &str, cgroup_limit: Option<&str>) -> Option<u64> {
    let total = ram_bytes_of(meminfo);
    let limit = cgroup_limit.and_then(|t| t.trim().parse::<u64>().ok());
    match (total, limit) {
        (Some(t), Some(l)) => Some(t.min(l)),
        (Some(t), None) => Some(t),
        (None, l) => l,
    }
}

/// Pure: `MemTotal` out of `/proc/meminfo`'s text, bytes.
pub fn ram_bytes_of(meminfo: &str) -> Option<u64> {
    let kb: u64 = meminfo
        .lines()
        .find_map(|l| l.strip_prefix("MemTotal:"))?
        .split_whitespace()
        .next()?
        .parse()
        .ok()?;
    Some(kb * 1024)
}

/// The proto shape of one served repository — the single place the wire fields
/// are filled from the trait, so `supports_listing`, `is_group` and `members`
/// can never drift between the catalog RPC and a proof that reads them.
pub fn repository_info(name: &str, repo: &dyn RepositoryBackendTrait) -> RepositoryInfo {
    let r = traits::RepositoryInfo::of(name, repo);
    RepositoryInfo {
        name: r.name,
        repo_type: r.repo_type,
        writable: r.writable,
        has_archive: r.has_archive,
        supports_listing: r.supports_listing,
        is_group: r.is_group,
        members: r.members,
        system: false,
    }
}

/// The sum of `archive_info().total_uncompressed_bytes` over every repository
/// that OWNS storage. A group is a view over its members and is skipped: its
/// `archive_info` already IS its members' sum, and adding it would count those
/// bytes twice.
///
/// A member whose archive cannot be read is a refusal naming that repository,
/// never a zero folded into the total. Blocking on purpose (`archive_info` reads
/// the archive index); the RPC runs it under `spawn_blocking`.
pub fn used_by_holger_bytes(
    repos: &[(String, Arc<dyn RepositoryBackendTrait>)],
) -> Result<u64, String> {
    let mut total = 0u64;
    for (name, repo) in repos {
        let caps = repo.caps();
        if caps.group || !caps.archive {
            continue;
        }
        let info = repo
            .archive()
            .map_err(|e| format!("the archive of repository `{name}` could not be read: {e}"))?
            .info();
        total = total.saturating_add(info.total_uncompressed_bytes);
    }
    Ok(total)
}

/// One `statvfs(3)` over `root` plus [`used_by_holger_bytes`] over `repos`.
pub fn measure(
    root: &Path,
    repos: &[(String, Arc<dyn RepositoryBackendTrait>)],
) -> Result<Capacity, String> {
    let fs = statvfs(root)?;
    let used_by_holger_bytes = used_by_holger_bytes(repos)?;
    Ok(Capacity {
        total_bytes: fs.total_bytes,
        available_bytes: fs.servable_bytes,
        used_by_holger_bytes,
        path: root.to_path_buf(),
        cpu_cores: cpu_cores(),
        ram_bytes: ram_bytes().unwrap_or(0),
    })
}

#[cfg(test)]
mod tests {
    /// ★ **The election sees the box's OWN memory, not its host's** (2026-10-25): in a
    /// container `/proc/meminfo` names the host (125 GiB on builder-64), so a 2 GB main and a
    /// 1 GB twin tied, and the twin was chosen by a disk test that is random under load. The
    /// cgroup's limit, when one is set, is what the box really has.
    #[test]
    fn a_cgroup_memory_limit_is_the_boxs_ram() {
        let host = "MemTotal:       131072000 kB\nMemFree: 1 kB\n";
        assert_eq!(
            ram_bytes_limited(host, Some("2147483648\n")),
            Some(2 << 30),
            "cgroup v2 memory.max: 2 GiB"
        );
        assert_eq!(ram_bytes_limited(host, Some("1073741824")), Some(1 << 30));
        assert_eq!(
            ram_bytes_limited(host, Some("max\n")),
            Some(131072000 * 1024),
            "no limit: MemTotal"
        );
        assert_eq!(
            ram_bytes_limited(host, None),
            Some(131072000 * 1024),
            "no cgroup file: MemTotal"
        );
        assert_eq!(
            ram_bytes_limited(host, Some("9223372036854771712\n")),
            Some(131072000 * 1024),
            "cgroup v1's \"unlimited\" is above MemTotal: MemTotal"
        );
        assert_eq!(
            ram_bytes_limited(host, Some("junk")),
            Some(131072000 * 1024),
            "an unreadable limit is ignored"
        );
        assert_eq!(
            ram_bytes_limited("no total here", Some("1073741824")),
            Some(1 << 30),
            "a limit without MemTotal is still the box's"
        );
    }

    use super::*;
    use crate::group::GroupBackend;
    use traits::{ArtifactEntry, ArtifactFormat, ArtifactId};

    /// ★ The election's RAM axis, read off `/proc/meminfo`'s own words (SB #37).
    #[test]
    fn the_ram_is_memtotal_in_bytes() {
        assert_eq!(
            ram_bytes_of("MemTotal:        2048000 kB\nMemFree:  1 kB\n"),
            Some(2_048_000 * 1024)
        );
        assert_eq!(ram_bytes_of("MemFree: 1 kB\n"), None);
        assert!(cpu_cores() >= 1);
    }

    /// A backend that owns a known number of archive bytes and nothing else.
    struct Store {
        name: String,
        bytes: u64,
    }

    impl RepositoryBackendTrait for Store {
        fn caps(&self) -> traits::Caps {
            traits::Caps {
                listing: true,
                archive: true,
                ..traits::Caps::NONE
            }
        }
        fn name(&self) -> &str {
            &self.name
        }
        fn format(&self) -> ArtifactFormat {
            ArtifactFormat::Raw
        }
        fn is_writable(&self) -> bool {
            false
        }
        fn fetch(&self, _id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
            Ok(None)
        }
        fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
            Err(anyhow::anyhow!("read-only"))
        }
        fn list(&self, _f: Option<&str>, _l: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
            Ok(Vec::new())
        }
        fn handle_http2_request(
            &self,
            _m: &str,
            _s: &str,
            _b: &[u8],
        ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
            Ok((404, vec![], Vec::new()))
        }
        fn archive(&self) -> anyhow::Result<traits::Archive> {
            Ok(traits::Archive {
                name: self.name.clone(),
                files: vec![("blob".into(), self.bytes)],
            })
        }
    }

    fn store(name: &str, bytes: u64) -> Arc<dyn RepositoryBackendTrait> {
        Arc::new(Store {
            name: name.into(),
            bytes,
        })
    }

    /// Two stores and a group over both — the fleet a double-count hides in.
    fn fleet() -> Vec<(String, Arc<dyn RepositoryBackendTrait>)> {
        let a = store("maven-releases", 1_000);
        let b = store("maven-snapshots", 24);
        let group: Arc<dyn RepositoryBackendTrait> = Arc::new(GroupBackend::new(
            "maven-all".into(),
            vec![a.clone(), b.clone()],
        ));
        vec![
            ("maven-releases".to_string(), a),
            ("maven-snapshots".to_string(), b),
            ("maven-all".to_string(), group),
        ]
    }

    #[test]
    fn a_group_on_the_wire_names_its_members_and_sums_their_bytes() {
        let fleet = fleet();
        let infos: Vec<RepositoryInfo> = fleet
            .iter()
            .map(|(n, r)| repository_info(n, r.as_ref()))
            .collect();

        let group = infos.iter().find(|i| i.name == "maven-all").unwrap();
        assert!(group.is_group, "the group must say it is a group");
        assert_eq!(group.members, vec!["maven-releases", "maven-snapshots"]);
        assert!(!group.writable, "a group is a read-only view");

        for member in infos.iter().filter(|i| i.name != "maven-all") {
            assert!(!member.is_group, "{} is not a group", member.name);
            assert!(member.members.is_empty(), "{} has no members", member.name);
        }

        let bytes = fleet[2]
            .1
            .archive()
            .unwrap()
            .info()
            .total_uncompressed_bytes;
        assert_eq!(
            bytes,
            1_000 + 24,
            "the group's bytes are the sum of its members'"
        );
    }

    #[test]
    fn a_fleet_sum_that_skips_groups_equals_the_sum_of_the_non_groups() {
        let fleet = fleet();
        let infos: Vec<RepositoryInfo> = fleet
            .iter()
            .map(|(n, r)| repository_info(n, r.as_ref()))
            .collect();

        // The proto-level rule a consumer applies: sum only what is not a group.
        let by_proto: u64 = infos
            .iter()
            .zip(&fleet)
            .filter(|(info, _)| !info.is_group)
            .map(|(_, (_, r))| r.archive().unwrap().info().total_uncompressed_bytes)
            .sum();
        assert_eq!(by_proto, 1_024);
        assert_eq!(used_by_holger_bytes(&fleet).unwrap(), by_proto);

        // And the lie this field exists to prevent: the naive sum is double.
        let naive: u64 = fleet
            .iter()
            .map(|(_, r)| r.archive().unwrap().info().total_uncompressed_bytes)
            .sum();
        assert_eq!(naive, 2 * 1_024);
    }

    #[test]
    fn an_unreadable_archive_is_a_named_refusal_not_a_zero() {
        struct Broken;
        impl RepositoryBackendTrait for Broken {
            fn caps(&self) -> traits::Caps {
                traits::Caps {
                    archive: true,
                    ..traits::Caps::NONE
                }
            }
            fn name(&self) -> &str {
                "broken"
            }
            fn format(&self) -> ArtifactFormat {
                ArtifactFormat::Raw
            }
            fn is_writable(&self) -> bool {
                false
            }
            fn fetch(&self, _id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
                Ok(None)
            }
            fn put(&self, _id: &ArtifactId, _d: &[u8]) -> anyhow::Result<()> {
                Err(anyhow::anyhow!("read-only"))
            }
            fn handle_http2_request(
                &self,
                _m: &str,
                _s: &str,
                _b: &[u8],
            ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
                Ok((404, vec![], Vec::new()))
            }
            fn archive(&self) -> anyhow::Result<traits::Archive> {
                Err(anyhow::anyhow!("index torn"))
            }
        }
        let fleet: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
            ("ok".into(), store("ok", 5)),
            ("broken".into(), Arc::new(Broken)),
        ];
        let err = used_by_holger_bytes(&fleet).unwrap_err();
        assert!(
            err.contains("`broken`") && err.contains("index torn"),
            "{err}"
        );
    }

    #[test]
    fn measure_reads_the_root_it_was_given() {
        let dir = tempfile::tempdir().unwrap();
        let cap = measure(dir.path(), &fleet()).unwrap();
        assert!(cap.total_bytes > 0);
        assert!(cap.available_bytes <= cap.total_bytes);
        assert_eq!(cap.used_by_holger_bytes, 1_024);
        assert_eq!(cap.path, dir.path());

        let gone = dir.path().join("not-here");
        let err = measure(&gone, &[]).unwrap_err();
        assert!(
            err.contains("statvfs(") && err.contains("not-here"),
            "{err}"
        );
    }
}