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
71pub(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
251pub(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}