Skip to main content

mj_controller/targets/
resources.rs

1use super::*;
2
3pub(super) const CGROUP_RESOURCE_USAGE_SCRIPT: &str = r#"
4for file in memory.current memory.max memory.swap.current memory.swap.max; do
5    path="/sys/fs/cgroup/$file"
6    if [ -r "$path" ]; then
7        printf "%s=%s\n" "$file" "$(cat "$path")"
8    fi
9done
10if [ -r /sys/fs/cgroup/cpu.stat ]; then
11    before=$(awk '/^usage_usec / { print $2 }' /sys/fs/cgroup/cpu.stat)
12    sleep 0.25
13    after=$(awk '/^usage_usec / { print $2 }' /sys/fs/cgroup/cpu.stat)
14    set -- $(cat /sys/fs/cgroup/cpu.max 2>/dev/null || printf 'max 100000')
15    if [ "$1" = max ]; then
16        cores=$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '1')
17    else
18        cores=$(awk -v quota="$1" -v period="$2" 'BEGIN { print quota / period }')
19    fi
20    awk -v used="$((after - before))" -v cores="$cores" \
21        'BEGIN { if (cores > 0) printf "cpu.percent=%.0f\n", used / 250000 / cores * 100 }'
22fi
23"#;
24
25pub(super) const HOST_RESOURCE_USAGE_SCRIPT: &str = r#"
26memory_proc_root=${1:-/proc}
27read_cpu() { awk '/^cpu / { total=0; for (i=2; i<=NF; i++) total += $i; print total, $5 + $6 }' /proc/stat; }
28set -- $(read_cpu); total_before=$1; idle_before=$2
29sleep 0.25
30set -- $(read_cpu); total_after=$1; idle_after=$2
31awk -v total="$((total_after - total_before))" -v idle="$((idle_after - idle_before))" \
32    'BEGIN { if (total > 0) printf "cpu.percent=%.0f\n", (total - idle) * 100 / total }'
33arc_size=0
34arc_min=0
35arcstats="$memory_proc_root/spl/kstat/zfs/arcstats"
36if [ -r "$arcstats" ]; then
37    set -- $(awk '
38        $1 == "c_min" { arc_min = $3 }
39        $1 == "size" { arc_size = $3 }
40        END { printf "%.0f %.0f\n", arc_size, arc_min }
41    ' "$arcstats")
42    arc_size=$1
43    arc_min=$2
44fi
45awk -v arc_size="$arc_size" -v arc_min="$arc_min" '
46    /^MemTotal:/ { memory_total = $2 }
47    /^MemAvailable:/ { memory_available = $2 }
48    /^SwapTotal:/ { swap_total = $2 }
49    /^SwapFree:/ { swap_free = $2 }
50    END {
51        memory_total *= 1024
52        memory_available *= 1024
53        # Like btop, count ARC above its minimum size as reclaimable cache.
54        if (arc_size > arc_min) memory_available += arc_size - arc_min
55        if (memory_available > memory_total) memory_available = memory_total
56        printf "memory.current=%.0f\n", memory_total - memory_available
57        printf "memory.max=%.0f\n", memory_total
58        printf "memory.swap.current=%.0f\n", (swap_total - swap_free) * 1024
59        printf "memory.swap.max=%.0f\n", swap_total * 1024
60    }
61' "$memory_proc_root/meminfo"
62printf 'logical.cores=%s\n' "$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc)"
63"#;
64
65pub(super) const AWS_ALLOCATED_CAPACITY_SCRIPT: &str = r#"
66awk '/^MemTotal:/ { printf "memory.total=%.0f\n", $2 * 1024 }' /proc/meminfo
67printf 'logical.cores=%s\n' "$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc)"
68df -B1 -P -- "$1" | awk 'NR == 2 { print "disk.total=" $2 }'
69"#;
70
71// `du` is run on its own so a path it cannot measure fails the probe instead of
72// being silently dropped from the total: a session that reports less disk than
73// it uses is worse than one that reports none. Its stderr is deliberately left
74// attached, so the caller's failure message names the path that could not be
75// read.
76pub(super) const AWS_SESSION_DISK_USAGE_SCRIPT: &str = r#"
77usage=$(du -sk "$@") || exit 1
78printf '%s\n' "$usage" | awk '{ total += $1 * 1024 } END { print total + 0 }'
79"#;
80
81pub fn resource_probe(locator: &TargetLocator, session_id: &str) -> Result<SessionResourceProbe> {
82    verify_locator(locator, session_id)?;
83    let (memory, disk) = match locator {
84        TargetLocator::LocalPodman { container_id, .. } => (
85            container_exec(
86                "podman",
87                container_id,
88                ["sh", "-c", CGROUP_RESOURCE_USAGE_SCRIPT],
89            )
90            .purpose("sample local Podman container resources"),
91            Some(
92                CommandSpec::new(
93                    "podman",
94                    [
95                        "container",
96                        "inspect",
97                        "--size",
98                        "--format",
99                        "{{.SizeRw}}",
100                        container_id,
101                    ],
102                )
103                .purpose("sample local Podman container writable disk"),
104            ),
105        ),
106        TargetLocator::LocalDocker { container_id, .. } => (
107            container_exec(
108                "docker",
109                container_id,
110                ["sh", "-c", CGROUP_RESOURCE_USAGE_SCRIPT],
111            )
112            .purpose("sample local Docker container resources"),
113            Some(
114                CommandSpec::new(
115                    "docker",
116                    [
117                        "container",
118                        "inspect",
119                        "--size",
120                        "--format",
121                        "{{.SizeRw}}",
122                        container_id,
123                    ],
124                )
125                .purpose("sample local Docker container writable disk"),
126            ),
127        ),
128        TargetLocator::SshPodman {
129            ssh, container_id, ..
130        }
131        | TargetLocator::SshDocker {
132            ssh, container_id, ..
133        } => (
134            ssh_command(
135                ssh,
136                [
137                    locator.container_engine().expect("remote container"),
138                    "exec",
139                    container_id,
140                    "sh",
141                    "-c",
142                    CGROUP_RESOURCE_USAGE_SCRIPT,
143                ],
144            )
145            .purpose("sample remote container resources"),
146            Some(
147                ssh_command(
148                    ssh,
149                    [
150                        locator.container_engine().expect("remote container"),
151                        "container",
152                        "inspect",
153                        "--size",
154                        "--format",
155                        "{{.SizeRw}}",
156                        container_id,
157                    ],
158                )
159                .purpose("sample remote container writable disk"),
160            ),
161        ),
162        TargetLocator::AwsEc2 { ssh, workspace, .. } => {
163            let worker_root = worker_root(locator, session_id)?;
164            let profile_root = format!(".local/share/hel/profiles/{session_id}");
165            (
166                ssh_command(ssh, ["sh", "-c", HOST_RESOURCE_USAGE_SCRIPT])
167                    .purpose("sample EC2 session resources"),
168                Some(
169                    ssh_command(
170                        ssh,
171                        [
172                            "sh",
173                            "-c",
174                            AWS_SESSION_DISK_USAGE_SCRIPT,
175                            "sh",
176                            workspace.as_str(),
177                            worker_root.as_str(),
178                            profile_root.as_str(),
179                        ],
180                    )
181                    .purpose("sample EC2 session disk"),
182                ),
183            )
184        }
185        TargetLocator::AppleContainer { container_id, .. } => (
186            container_exec(
187                "container",
188                container_id,
189                ["sh", "-c", CGROUP_RESOURCE_USAGE_SCRIPT],
190            )
191            .purpose("sample Apple container resources"),
192            None,
193        ),
194        TargetLocator::LocalBare { .. } | TargetLocator::SshBare { .. } => {
195            bail!("resource sampling is unsupported for this target")
196        }
197    };
198    Ok(SessionResourceProbe { memory, disk })
199}
200
201pub fn parse_resource_usage(
202    memory_output: &[u8],
203    disk_output: Option<&[u8]>,
204) -> Result<SessionResourceUsage> {
205    let mut values = BTreeMap::new();
206    let memory_text = String::from_utf8_lossy(memory_output);
207    for line in memory_text.lines() {
208        let Some((name, value)) = line.split_once('=') else {
209            continue;
210        };
211        values.insert(name, value.trim());
212    }
213
214    let memory_current_bytes = parse_cgroup_counter(
215        values
216            .get("memory.current")
217            .context("resource probe did not expose memory.current")?,
218    )?
219    .context("resource probe reported memory.current as unlimited")?;
220    let memory_limit_bytes = values
221        .get("memory.max")
222        .map(|value| parse_cgroup_counter(value))
223        .transpose()?
224        .flatten();
225    let swap_current_bytes = values
226        .get("memory.swap.current")
227        .map(|value| parse_cgroup_counter(value))
228        .transpose()?
229        .flatten();
230    let swap_limit_bytes = values
231        .get("memory.swap.max")
232        .map(|value| parse_cgroup_counter(value))
233        .transpose()?
234        .flatten();
235    let writable_disk_bytes = disk_output.map(parse_disk_usage).transpose()?;
236    let cpu_percent = values
237        .get("cpu.percent")
238        .map(|value| parse_percent(value))
239        .transpose()?;
240
241    Ok(SessionResourceUsage {
242        cpu_percent,
243        memory_current_bytes,
244        memory_limit_bytes,
245        swap_current_bytes,
246        swap_limit_bytes,
247        writable_disk_bytes,
248    })
249}
250
251/// Read the single byte count every writable-disk probe answers with.
252///
253/// A probe that ran and answered something else measured nothing, which must be
254/// reported as a failure rather than silently becoming "disk usage unknown":
255/// only a probe that was never run leaves the value unknown.
256pub(super) fn parse_disk_usage(output: &[u8]) -> Result<u64> {
257    let text = String::from_utf8_lossy(output);
258    let text = text.trim();
259    text.parse()
260        .with_context(|| format!("disk usage probe answered {text:?} instead of a byte count"))
261}
262
263pub fn ssh_host_capacity_command(ssh: &SshTarget) -> CommandSpec {
264    ssh_command(ssh, ["sh", "-c", HOST_RESOURCE_USAGE_SCRIPT])
265        .purpose("sample deployment host capacity")
266}
267
268pub fn aws_allocated_capacity_command(
269    locator: &TargetLocator,
270    session_id: &str,
271) -> Result<CommandSpec> {
272    let TargetLocator::AwsEc2 { workspace, .. } = locator else {
273        bail!("AWS allocated-capacity probes require an EC2 locator");
274    };
275    command_on_locator(
276        locator,
277        session_id,
278        vec![
279            "sh".into(),
280            "-c".into(),
281            AWS_ALLOCATED_CAPACITY_SCRIPT.into(),
282            "sh".into(),
283            workspace.clone(),
284        ],
285        "sample EC2 allocated capacity",
286    )
287}
288
289pub fn parse_host_capacity(output: &[u8]) -> Result<DeploymentCapacityUsage> {
290    let values = parse_key_values(output);
291    let total = parse_required_u64(&values, "memory.max")?;
292    Ok(DeploymentCapacityUsage {
293        cpu_percent: Some(parse_percent(required_value(&values, "cpu.percent")?)?),
294        memory_used_bytes: parse_required_u64(&values, "memory.current")?,
295        memory_total_bytes: total,
296        logical_cores: parse_required_u64(&values, "logical.cores")?,
297        disk_total_bytes: None,
298    })
299}
300
301pub fn parse_aws_allocated_capacity(output: &[u8]) -> Result<DeploymentCapacityUsage> {
302    let values = parse_key_values(output);
303    let memory_total_bytes = parse_required_u64(&values, "memory.total")?;
304    Ok(DeploymentCapacityUsage {
305        cpu_percent: None,
306        memory_used_bytes: 0,
307        memory_total_bytes,
308        logical_cores: parse_required_u64(&values, "logical.cores")?,
309        disk_total_bytes: Some(parse_required_u64(&values, "disk.total")?),
310    })
311}
312
313pub(super) fn parse_key_values(output: &[u8]) -> BTreeMap<String, String> {
314    String::from_utf8_lossy(output)
315        .lines()
316        .filter_map(|line| line.split_once('='))
317        .map(|(key, value)| (key.to_owned(), value.trim().to_owned()))
318        .collect()
319}
320
321pub(super) fn required_value<'a>(
322    values: &'a BTreeMap<String, String>,
323    key: &str,
324) -> Result<&'a str> {
325    values
326        .get(key)
327        .map(String::as_str)
328        .with_context(|| format!("capacity probe did not expose {key}"))
329}
330
331pub(super) fn parse_required_u64(values: &BTreeMap<String, String>, key: &str) -> Result<u64> {
332    required_value(values, key)?
333        .parse()
334        .with_context(|| format!("capacity probe reported invalid {key}"))
335}
336
337pub(super) fn parse_percent(value: &str) -> Result<u8> {
338    let value: f64 = value
339        .parse()
340        .with_context(|| format!("invalid percentage {value:?}"))?;
341    if !value.is_finite() {
342        bail!("invalid percentage {value:?}");
343    }
344    Ok(value.round().clamp(0.0, 100.0) as u8)
345}
346
347pub(super) fn parse_cgroup_counter(value: &str) -> Result<Option<u64>> {
348    if value == "max" {
349        return Ok(None);
350    }
351    Ok(Some(value.parse().with_context(|| {
352        format!("invalid memory counter {value:?}")
353    })?))
354}