diff --git a/.gitignore b/.gitignore index c317f6dc81..3f4afc0504 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules -venv \ No newline at end of file +venv + +CLAUDE.md diff --git a/providers/base/bin/iostat_benchmark.py b/providers/base/bin/iostat_benchmark.py new file mode 100755 index 0000000000..b7229d79d4 --- /dev/null +++ b/providers/base/bin/iostat_benchmark.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# This file is part of Checkbox. +# +# Copyright 2026 Canonical Ltd. +# Written by: +# Jeff Lane +# +# Checkbox is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# Checkbox is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Checkbox. If not, see . + +import argparse +import re +import subprocess +import sys + + +def parse_iostat_column(output, column): + values = [float(n) for n in re.findall(column + r"\n.*?(\S+)\n", output)] + if not values: + print( + "ERROR: No '{}' values found in iostat output".format(column), + file=sys.stderr, + ) + return 1 + print("{:.2f}%".format(sum(values) / len(values))) + return 0 + + +def main(): + parser = argparse.ArgumentParser( + description="Measure average CPU or disk utilization from iostat." + ) + parser.add_argument( + "metric", + choices=["cpu", "disk"], + help="Which metric to report: 'cpu' (idle %%) or 'disk' (util %%)", + ) + parser.add_argument( + "-t", + "--time", + action="store", + default=10, + help="Time in seconds to run iostat. (default: %(default)s)", + ) + args = parser.parse_args() + + column = "idle" if args.metric == "cpu" else "util" + + try: + result = subprocess.run( + ["iostat", "-x", "-m", "1", str(args.time)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + check=True, + ) + except subprocess.CalledProcessError as e: + print("ERROR: iostat failed: {}".format(e), file=sys.stderr) + return 1 + + return parse_iostat_column(result.stdout, column) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/providers/base/tests/test_iostat_benchmark.py b/providers/base/tests/test_iostat_benchmark.py new file mode 100644 index 0000000000..f093f5ebf8 --- /dev/null +++ b/providers/base/tests/test_iostat_benchmark.py @@ -0,0 +1,112 @@ +import subprocess +import unittest +from io import StringIO +from unittest.mock import patch + +import iostat_benchmark + +IOSTAT_OUTPUT = """\ +Linux 6.8.0-57-generic (hostname) 04/27/2026 _x86_64_ (8 CPU) + +avg-cpu: %user %nice %system %iowait %steal %idle + 0.50 0.00 0.25 0.10 0.00 99.15 + +Device r/s rMB/s rrqm/s %rrqm r_await rareq-sz w/s wMB/s wrqm/s %wrqm w_await wareq-sz d/s dMB/s drqm/s %drqm d_await dareq-sz f/s f_await aqu-sz %util +sda 0.10 0.00 0.00 0.00 0.50 10.00 1.00 0.01 0.50 33.33 5.00 10.24 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.01 0.10 + +avg-cpu: %user %nice %system %iowait %steal %idle + 0.60 0.00 0.30 0.05 0.00 99.05 + +Device r/s rMB/s rrqm/s %rrqm r_await rareq-sz w/s wMB/s wrqm/s %wrqm w_await wareq-sz d/s dMB/s drqm/s %drqm d_await dareq-sz f/s f_await aqu-sz %util +sda 0.00 0.00 0.00 0.00 0.00 0.00 0.50 0.00 0.25 33.33 4.00 8.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.20 + +""" + + +class TestParseIostatColumn(unittest.TestCase): + def test_cpu_idle_average(self): + out = StringIO() + with patch("sys.stdout", out): + ret = iostat_benchmark.parse_iostat_column(IOSTAT_OUTPUT, "idle") + self.assertEqual(ret, 0) + # (99.15 + 99.05) / 2 = 99.1 + self.assertIn("99.1", out.getvalue()) + + def test_disk_util_average(self): + out = StringIO() + with patch("sys.stdout", out): + ret = iostat_benchmark.parse_iostat_column(IOSTAT_OUTPUT, "util") + self.assertEqual(ret, 0) + # (0.10 + 0.20) / 2 = 0.15 + self.assertIn("0.15", out.getvalue()) + + def test_missing_column_returns_error(self): + err = StringIO() + with patch("sys.stderr", err): + ret = iostat_benchmark.parse_iostat_column( + "no output here", "idle" + ) + self.assertEqual(ret, 1) + self.assertIn("idle", err.getvalue()) + + +class TestMain(unittest.TestCase): + @patch( + "iostat_benchmark.subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout=IOSTAT_OUTPUT + ), + ) + def test_main_cpu(self, mock_run): + out = StringIO() + with patch("sys.stdout", out), patch( + "sys.argv", ["iostat_benchmark.py", "cpu"] + ): + ret = iostat_benchmark.main() + self.assertEqual(ret, 0) + self.assertEqual(mock_run.call_count, 1) + args, kwargs = mock_run.call_args + self.assertEqual(args[0], ["iostat", "-x", "-m", "1", "10"]) + self.assertTrue(kwargs.get("check")) + self.assertTrue( + ( + kwargs.get("capture_output") is True + and kwargs.get("text") is True + ) + or ( + kwargs.get("stdout") == subprocess.PIPE + and kwargs.get("stderr") == subprocess.PIPE + and kwargs.get("universal_newlines") is True + ) + ) + + @patch( + "iostat_benchmark.subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout=IOSTAT_OUTPUT + ), + ) + def test_main_disk(self, mock_run): + out = StringIO() + with patch("sys.stdout", out), patch( + "sys.argv", ["iostat_benchmark.py", "disk"] + ): + ret = iostat_benchmark.main() + self.assertEqual(ret, 0) + + @patch( + "iostat_benchmark.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "iostat"), + ) + def test_main_iostat_failure(self, _mock_run): + err = StringIO() + with patch("sys.stderr", err), patch( + "sys.argv", ["iostat_benchmark.py", "cpu"] + ): + ret = iostat_benchmark.main() + self.assertEqual(ret, 1) + self.assertIn("iostat failed", err.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/providers/base/units/benchmarks/jobs.pxu b/providers/base/units/benchmarks/jobs.pxu index 70ab920549..422f77a6fe 100644 --- a/providers/base/units/benchmarks/jobs.pxu +++ b/providers/base/units/benchmarks/jobs.pxu @@ -88,7 +88,7 @@ category_id: com.canonical.plainbox::benchmarks id: benchmarks/system/cpu_on_idle estimated_duration: 10.0 requires: package.name == 'sysstat' -command: iostat -x -m 1 10 | python3 -c 'import sys, re; lines="".join(sys.stdin.readlines()); l=[float(n) for n in (re.findall("idle\n.*?(\S+)\n", lines))]; print(sum(l)/len(l),"%")' +command: iostat_benchmark.py cpu _purpose: CPU utilization on an idle system. _summary: Measure CPU utilization on an idle system. @@ -97,7 +97,7 @@ category_id: com.canonical.plainbox::benchmarks id: benchmarks/system/disk_on_idle estimated_duration: 10.0 requires: package.name == 'sysstat' -command: iostat -x -m 1 10 | python3 -c 'import sys, re; lines="".join(sys.stdin.readlines()); l=[float(n) for n in (re.findall("util\n.*?(\S+)\n", lines))]; print(sum(l)/len(l),"%")' +command: iostat_benchmark.py disk _purpose: Disk utilization on an idle system. _summary: Measure disk utilization on an idle system.