From c28b30cfa9c936b65c8d21f774bdc348f4d2115a Mon Sep 17 00:00:00 2001 From: Jeff Lane Date: Tue, 21 Apr 2026 09:44:34 -0400 Subject: [PATCH 1/6] (BugFix) fix regex in benchmarks cpu/disk_on_idle * for disk_on_idle and cpu_on_idle, fix regex to resolve warning about invalid escape char in newer python3 versions. (older Python3 just silently passed it) * Also added CLAUDE.md to gitignore to avoid accidentally pulling in Claude instructions when making commits. Fixes #2477 --- .gitignore | 4 +++- providers/base/units/benchmarks/jobs.pxu | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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/units/benchmarks/jobs.pxu b/providers/base/units/benchmarks/jobs.pxu index 70ab920549..c85fcf8ed3 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 -x -m 1 10 | python3 -c 'import sys, re; lines="".join(sys.stdin.readlines()); l=[float(n) for n in (re.findall(r"idle\n.*?(\S+)\n", lines))]; print(sum(l)/len(l),"%")' _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 -x -m 1 10 | python3 -c 'import sys, re; lines="".join(sys.stdin.readlines()); l=[float(n) for n in (re.findall(r"util\n.*?(\S+)\n", lines))]; print(sum(l)/len(l),"%")' _purpose: Disk utilization on an idle system. _summary: Measure disk utilization on an idle system. From 10d6750cc1465df94361463436a7a2445d6745a6 Mon Sep 17 00:00:00 2001 From: Jeff Lane Date: Mon, 27 Apr 2026 12:22:30 -0400 Subject: [PATCH 2/6] Convert the shell command w/ embedded python into a small python script, add unit tests, and update the job descriptions accordingly --- providers/base/bin/iostat_benchmark.py | 75 +++++++++++++ providers/base/tests/test_iostat_benchmark.py | 102 ++++++++++++++++++ providers/base/units/benchmarks/jobs.pxu | 4 +- 3 files changed, 179 insertions(+), 2 deletions(-) create mode 100755 providers/base/bin/iostat_benchmark.py create mode 100644 providers/base/tests/test_iostat_benchmark.py diff --git a/providers/base/bin/iostat_benchmark.py b/providers/base/bin/iostat_benchmark.py new file mode 100755 index 0000000000..68ebb2b15e --- /dev/null +++ b/providers/base/bin/iostat_benchmark.py @@ -0,0 +1,75 @@ +#!/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(rf"{column}\n.*?(\S+)\n", output) + ] + if not values: + print( + f"ERROR: No '{column}' values found in iostat output", + file=sys.stderr, + ) + return 1 + print(f"{sum(values) / len(values):.2f}%") + 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)], + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as e: + print(f"ERROR: iostat failed: {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..212b64b1a8 --- /dev/null +++ b/providers/base/tests/test_iostat_benchmark.py @@ -0,0 +1,102 @@ +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) + mock_run.assert_called_once_with( + ["iostat", "-x", "-m", "1", "10"], + capture_output=True, + text=True, + check=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 c85fcf8ed3..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(r"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(r"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. From a52bb97e4878ecc05ca2a78725a14fd5c794c9ab Mon Sep 17 00:00:00 2001 From: Jeff Lane Date: Mon, 27 Apr 2026 12:40:02 -0400 Subject: [PATCH 3/6] update based on copilot suggestion for 3.6 compatibility --- providers/base/bin/iostat_benchmark.py | 5 +++-- providers/base/tests/test_iostat_benchmark.py | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/providers/base/bin/iostat_benchmark.py b/providers/base/bin/iostat_benchmark.py index 68ebb2b15e..7b16504568 100755 --- a/providers/base/bin/iostat_benchmark.py +++ b/providers/base/bin/iostat_benchmark.py @@ -60,8 +60,9 @@ def main(): try: result = subprocess.run( ["iostat", "-x", "-m", "1", str(args.time)], - capture_output=True, - text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, check=True, ) except subprocess.CalledProcessError as e: diff --git a/providers/base/tests/test_iostat_benchmark.py b/providers/base/tests/test_iostat_benchmark.py index 212b64b1a8..2ad7e3f185 100644 --- a/providers/base/tests/test_iostat_benchmark.py +++ b/providers/base/tests/test_iostat_benchmark.py @@ -63,11 +63,20 @@ def test_main_cpu(self, mock_run): ): ret = iostat_benchmark.main() self.assertEqual(ret, 0) - mock_run.assert_called_once_with( - ["iostat", "-x", "-m", "1", "10"], - capture_output=True, - text=True, - check=True, + mock_run.assert_called_once() + 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( From 8f6f398e210574470d31d004bb7fdd9ba481b1cd Mon Sep 17 00:00:00 2001 From: Jeff Lane Date: Mon, 27 Apr 2026 12:47:44 -0400 Subject: [PATCH 4/6] fix black formatting --- providers/base/bin/iostat_benchmark.py | 10 ++++------ providers/base/tests/test_iostat_benchmark.py | 5 +++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/providers/base/bin/iostat_benchmark.py b/providers/base/bin/iostat_benchmark.py index 7b16504568..f3715251fa 100755 --- a/providers/base/bin/iostat_benchmark.py +++ b/providers/base/bin/iostat_benchmark.py @@ -24,10 +24,7 @@ def parse_iostat_column(output, column): - values = [ - float(n) - for n in re.findall(rf"{column}\n.*?(\S+)\n", output) - ] + values = [float(n) for n in re.findall(rf"{column}\n.*?(\S+)\n", output)] if not values: print( f"ERROR: No '{column}' values found in iostat output", @@ -48,10 +45,11 @@ def main(): help="Which metric to report: 'cpu' (idle %%) or 'disk' (util %%)", ) parser.add_argument( - "-t","--time", + "-t", + "--time", action="store", default=10, - help="Time in seconds to run iostat. (default: %(default)s)" + help="Time in seconds to run iostat. (default: %(default)s)", ) args = parser.parse_args() diff --git a/providers/base/tests/test_iostat_benchmark.py b/providers/base/tests/test_iostat_benchmark.py index 2ad7e3f185..502893f6b8 100644 --- a/providers/base/tests/test_iostat_benchmark.py +++ b/providers/base/tests/test_iostat_benchmark.py @@ -5,7 +5,6 @@ import iostat_benchmark - IOSTAT_OUTPUT = """\ Linux 6.8.0-57-generic (hostname) 04/27/2026 _x86_64_ (8 CPU) @@ -44,7 +43,9 @@ def test_disk_util_average(self): def test_missing_column_returns_error(self): err = StringIO() with patch("sys.stderr", err): - ret = iostat_benchmark.parse_iostat_column("no output here", "idle") + ret = iostat_benchmark.parse_iostat_column( + "no output here", "idle" + ) self.assertEqual(ret, 1) self.assertIn("idle", err.getvalue()) From b38d4da993d251bb066c557040b2e273201378c0 Mon Sep 17 00:00:00 2001 From: Jeff Lane Date: Mon, 27 Apr 2026 13:01:31 -0400 Subject: [PATCH 5/6] update to provide python 3.5 compatibility --- providers/base/bin/iostat_benchmark.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/providers/base/bin/iostat_benchmark.py b/providers/base/bin/iostat_benchmark.py index f3715251fa..b7229d79d4 100755 --- a/providers/base/bin/iostat_benchmark.py +++ b/providers/base/bin/iostat_benchmark.py @@ -24,14 +24,14 @@ def parse_iostat_column(output, column): - values = [float(n) for n in re.findall(rf"{column}\n.*?(\S+)\n", output)] + values = [float(n) for n in re.findall(column + r"\n.*?(\S+)\n", output)] if not values: print( - f"ERROR: No '{column}' values found in iostat output", + "ERROR: No '{}' values found in iostat output".format(column), file=sys.stderr, ) return 1 - print(f"{sum(values) / len(values):.2f}%") + print("{:.2f}%".format(sum(values) / len(values))) return 0 @@ -64,7 +64,7 @@ def main(): check=True, ) except subprocess.CalledProcessError as e: - print(f"ERROR: iostat failed: {e}", file=sys.stderr) + print("ERROR: iostat failed: {}".format(e), file=sys.stderr) return 1 return parse_iostat_column(result.stdout, column) From 766b01a4315b4bd78d47d5d335ef7a48b1ff5806 Mon Sep 17 00:00:00 2001 From: Jeff Lane Date: Mon, 27 Apr 2026 13:26:06 -0400 Subject: [PATCH 6/6] fix test for 3.5 --- providers/base/tests/test_iostat_benchmark.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/base/tests/test_iostat_benchmark.py b/providers/base/tests/test_iostat_benchmark.py index 502893f6b8..f093f5ebf8 100644 --- a/providers/base/tests/test_iostat_benchmark.py +++ b/providers/base/tests/test_iostat_benchmark.py @@ -64,7 +64,7 @@ def test_main_cpu(self, mock_run): ): ret = iostat_benchmark.main() self.assertEqual(ret, 0) - mock_run.assert_called_once() + 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"))