diff --git a/.github/workflows/analyzers.yml b/.github/workflows/analyzers.yml new file mode 100644 index 000000000..8cec429a5 --- /dev/null +++ b/.github/workflows/analyzers.yml @@ -0,0 +1,77 @@ +name: Static analyzers + +on: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + tooling: + name: Tooling + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Check analyzer tooling + run: make lint-test + + native: + name: ${{ matrix.analyzer }} with KUnit + needs: tooling + runs-on: ubuntu-24.04 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + analyzer: + - sparse + - smatch + # Supported by the runner; enable after reviewing native coverage. + # - coccicheck + # - cppcheck + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Build pinned analyzer environment + run: | + docker build -f scripts/ci/analyzers.Dockerfile -t scst-analyzers:local . \ + 2>&1 | tee "$RUNNER_TEMP/analyzer-image.log" + - name: Run make ${{ matrix.analyzer }} + env: + ANALYZER: ${{ matrix.analyzer }} + run: | + source scripts/ci/kernel.env + # Temporary rollout mode: only completed source findings are advisory. + # Tool, build and coverage failures still fail this job. + docker run --rm --init --network=none --cap-drop=ALL \ + --security-opt=no-new-privileges --user "$(id -u):$(id -g)" \ + --env HOME=/tmp --env GITHUB_STEP_SUMMARY=/scst/.cache/analyzer-summary \ + --env "KDIR=/usr/src/kernels/$KERNEL_BUILD.x86_64" \ + --mount "type=bind,src=$PWD,dst=/scst" scst-analyzers:local \ + python3 -B scripts/ci/run-analyzer.py "$ANALYZER" + - name: Publish summary + if: always() + run: | + if [[ -f .cache/analyzer-summary ]]; then + cat .cache/analyzer-summary >> "$GITHUB_STEP_SUMMARY" + fi + - name: Save complete analyzer evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.analyzer }}-evidence + retention-days: 7 + path: | + ${{ runner.temp }}/analyzer-image.log + .cache/lint/ + .cache/analyzer-summary diff --git a/doc/CI.md b/doc/CI.md index bf2500870..030adb16a 100644 --- a/doc/CI.md +++ b/doc/CI.md @@ -9,6 +9,7 @@ Workflows live in [`.github/workflows`](../.github/workflows). PR checks target | [CI](../.github/workflows/ci.yml) | Clang build and DEB packaging | PR, push, manual | | [Packages](../.github/workflows/rpm.yml) | Ubuntu DEBs, RPMs and a published package snapshot | Push to master only | | [Regression](../.github/workflows/run_regression_tests.yaml) | Module and patched-kernel builds across the kernel matrix | PR, push | +| [Static analyzers](../.github/workflows/analyzers.yml) | Sparse/Smatch with KUnit sources, tooling checks and full reports | Push to master, manual | | [KUnit](../.github/workflows/kunit.yml) | Kernel unit tests in QEMU | PR, push, manual | | [Ginkgo](../.github/workflows/ginkgo.yml) | Local and TCP iSCSI component tests in QEMU | PR, push, manual | | [Coverity](../.github/workflows/coverity.yml) | Static analysis | Push, upstream repository only | @@ -39,6 +40,10 @@ kernel diagnostics and incomplete reports fail the run. Package artifacts are attached to the Packages workflow; upstream master builds also update the `master-rpm` prerelease snapshot. +Analyzer jobs retain full reports in CI artifacts. See the +[analyzer guide](../scripts/README.md#analyzer-ci) for reproduction and the +temporary nonblocking policy for source findings. + ## Local reproduction Use the commands in the relevant workflow for build and package checks; diff --git a/scripts/README.md b/scripts/README.md index 14f85ef4f..b9084c013 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -232,6 +232,19 @@ analysis. Source findings return 1 and incomplete analysis returns 2; Make maps both to 2. Full logs and the summary are in `.cache/lint/cppcheck.{log,json}`; the summary links to captured commands, native XML and checker coverage. +## Analyzer CI + +The [workflow](../.github/workflows/analyzers.yml) runs on pushes to `master` +and manual dispatch. Separate jobs run `make sparse` and `make smatch` with +`KUNIT_TEST=y`. Reports in `.cache/lint/` are kept for seven days. Use the +commands above to run either analyzer locally. The runner and image also +support Coccinelle and Cppcheck; their matrix entries remain commented out. + +CI temporarily reports completed source findings without blocking while fixes +land. Build/tool failures, incomplete analysis and missing KUnit coverage +still fail. This policy lives in `ci/run-analyzer.py`; local Make targets +always fail on findings. + ## Dependencies and tooling tests Shared analyzer tooling requires Linux, Git and Python 3.10+, using only the diff --git a/scripts/ci/analyzers.Dockerfile b/scripts/ci/analyzers.Dockerfile index 9a555271a..3ad4f8e5e 100644 --- a/scripts/ci/analyzers.Dockerfile +++ b/scripts/ci/analyzers.Dockerfile @@ -3,7 +3,7 @@ FROM fedora:44@sha256:43b29f65a41eb9c35e1cd5323e3bdf3b655c2357a9f4f1ff2f9c2798e5 RUN dnf install -y --setopt=install_weak_deps=False \ gcc make git python3 perl elfutils-libelf-devel openssl openssl-devel \ sqlite-devel flex bison pkgconf-pkg-config diffutils findutils \ - curl-minimal xz tar gzip dwarves && dnf clean all + curl-minimal xz tar gzip dwarves coccinelle cppcheck && dnf clean all COPY scripts/ci/kernel.env scripts/ci/kernel.SHA256SUMS /opt/scst-ci/ COPY scripts/ci/analyzers.env scripts/ci/install-analyzers /opt/scst-ci/ diff --git a/scripts/ci/run-analyzer.py b/scripts/ci/run-analyzer.py new file mode 100644 index 000000000..db325762c --- /dev/null +++ b/scripts/ci/run-analyzer.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Run one analyzer Make target with CI coverage and failure checks.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import uuid + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) +from analyzer_policy import source_policy # noqa: E402 +from lint_common import copy_output, entrypoint, run # noqa: E402 + +TOOLS = {"sparse": "sparse", "smatch": "smatch", "coccicheck": "spatch", "cppcheck": "cppcheck"} + + +def complete_native_report(report): + tool = report.get("tool") + if tool in {"sparse", "smatch"}: + return (bool(report.get("translation_units")) and report.get("make_status") == 0 and + report.get("errors") == 0 and not report.get("unchecked_files") and + not report.get("unrecorded_checks")) + if tool == "coccicheck": + rules = report.get("rules") + return (isinstance(rules, list) and bool(rules) and all( + row.get("complete") is True and row.get("native_status") == 0 and + not row.get("failures") and row.get("handled") for row in rules)) + if tool == "cppcheck": + profiles = report.get("profiles") + return (report.get("kernel_make_status") == 0 and + report.get("userspace_make_status") == 0 and + not report.get("unchecked_files") and not report.get("unrecorded_checks") and + isinstance(profiles, list) and len(profiles) == 2 and + {row.get("profile") for row in profiles} == {"kernel", "userspace"} and all( + row.get("complete") is True and row.get("native_status") in {0, 3} and + not row.get("failures") and row.get("translation_units") and + row.get("checked_files") for row in profiles)) + return False + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def job_status(make_status, report): + """Only a completed scan with source findings may become advisory.""" + status = report.get("exit_code") + if (report.get("tool") not in TOOLS or + type(status) is not int or status not in (0, 1) or + make_status != (2 if status else 0) or + report.get("outcome") != ("findings" if status else "clean") or + not complete_native_report(report) or + not isinstance(report.get("checked_files"), list)): + return 2 + remaining = report.get("remaining") + if type(remaining) is not int or remaining < 0 or bool(status) != bool(remaining): + return 2 + tool = report["tool"] + scope = source_policy(tool) + excluded = {rule["path"] for rule in scope["exclude_directories"]} + roots = [root for root in scope["source_roots"] if root not in excluded] + checked = report["checked_files"] + if (any(not any(path.startswith(root + "/") for path in checked) for root in roots) or + not any("/kunit/" in path for path in checked)): + print("CI requires analysis records for every module component and KUnit sources, " + "plus userspace where selected by policy.", + file=sys.stderr) + return 2 + # Temporary rollout policy: completed source findings are advisory. + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("tool", choices=tuple(TOOLS)) + args = parser.parse_args() + kernel = Path(os.environ.get("SCST_LINT_KDIR") or os.environ.get("KDIR", "")) + if not (kernel / "Module.symvers").is_file(): + raise ValueError("Set KDIR to a prepared kernel build tree with Module.symvers") + config = (kernel / ".config").read_text() + if not any(line in {"CONFIG_KUNIT=y", "CONFIG_KUNIT=m"} for line in config.splitlines()): + raise ValueError("The CI analysis kernel must enable CONFIG_KUNIT") + executable = shutil.which(TOOLS[args.tool]) + if not executable: + raise ValueError(f"{args.tool} is not installed") + directory = ROOT / ".cache/lint" + directory.mkdir(parents=True, exist_ok=True) + provenance = Path("/usr/local/share/scst-ci") / (args.tool + "-source.txt") + environment = { + "commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(), + "kernel": str(kernel.resolve()), "kernel_config_sha256": digest(kernel / ".config"), + "tool": executable, "tool_sha256": digest(Path(executable)), + "version": subprocess.check_output([executable, "--version"], text=True).strip(), + "source_provenance": provenance.read_text() if provenance.exists() else "unavailable", + "kunit_test": "y", + } + environment_text = json.dumps(environment, indent=2) + "\n" + (directory / f"{args.tool}.environment.json").write_text(environment_text) + print(environment_text, flush=True) + (directory / "kernel.config").write_text(config) + models = provenance.with_name("smatch-data.sha256") + if args.tool == "smatch" and models.exists(): + shutil.copyfile(models, directory / "smatch-data.sha256") + policy = subprocess.check_output([sys.executable, "-B", str(ROOT / "scripts/analyzer_policy.py"), + "show", args.tool, "--format=json"], text=True) + (directory / f"{args.tool}.policy.json").write_text(policy) + print(policy, flush=True) + identity = str(uuid.uuid4()) + env = dict(os.environ, KDIR=str(kernel.resolve()), SCST_LINT_KDIR=str(kernel.resolve()), + KUNIT_TEST="y", KUNIT_LABEL="ALL", DIFFBR="", LINT_DEFER_REPORTS="1", + SCST_LINT_RUN_ID=identity) + report = {} + try: + # Make maps both findings and driver failures to 2. The current run's + # report distinguishes them; neither a failed make nor a report alone + # is sufficient to classify the run as completed source findings. + result = run(["make", "-j2", args.tool, f"KDIR={kernel.resolve()}", + "KUNIT_TEST=y", "KUNIT_LABEL=ALL", "DIFFBR=", "WERROR=n"], cwd=ROOT, env=env) + finally: + path = directory / f"{args.tool}.json" + if path.exists(): + candidate = json.loads(path.read_text()) + if (isinstance(candidate, dict) and candidate.get("run_id") == identity and + candidate.get("tool") == args.tool): + report = candidate + log = directory / f"{args.tool}.log" + if log.exists(): + print(f"\nFull report: {log}", flush=True) + copy_output(log, sys.stdout.buffer) + status = job_status(result.returncode, report) + if status == 0 and report["exit_code"] == 1: + print(f"::warning::{args.tool}: {report['remaining']} source finding(s); " + "temporary report mode, analysis completed.") + summary = (f"{args.tool}: outcome={report.get('outcome', 'missing')}, " + f"remaining={report.get('remaining', 'unknown')}, " + f"make exit={result.returncode}, job exit={status}\n") + print(summary, end="") + if os.environ.get("GITHUB_STEP_SUMMARY"): + with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as stream: + stream.write(summary) + return status + + +if __name__ == "__main__": + sys.exit(entrypoint(main)) diff --git a/scripts/tests/test_analyzer_ci.py b/scripts/tests/test_analyzer_ci.py new file mode 100644 index 000000000..273e8a712 --- /dev/null +++ b/scripts/tests/test_analyzer_ci.py @@ -0,0 +1,153 @@ +"""Exercise Make exit mapping and CI reports without a build or container.""" + +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile +import unittest + + +class AnalyzerCiTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="scst-analyzer-ci-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + repository = Path(__file__).resolve().parents[2] + shutil.copytree(repository / "scripts", self.root / "scripts", + ignore=shutil.ignore_patterns("__pycache__")) + self.bin = self.root / "bin" + self.bin.mkdir() + (self.bin / "make").symlink_to(shutil.which("make")) + # Exercise real GNU Make's mapping of recipe failures to status 2. + # The fixture driver below produces reports without compiling SCST. + (self.root / "Makefile").write_text( + "SHELL := /bin/sh\n.PHONY: sparse smatch coccicheck cppcheck\n" + "sparse smatch coccicheck cppcheck:\n" + f"\t@{shlex.quote(sys.executable)} -B scripts/sparse_smatch/smp.py $@\n" + "\t@exit $(FIXTURE_MAKE_TAIL_EXIT)\n") + for name in ("git", "sparse", "smatch", "spatch", "cppcheck"): + path = self.bin / name + path.write_text(f"#!{sys.executable}\nprint('fixture-version')\n") + path.chmod(0o755) + kernel = self.root / "kernel" + kernel.mkdir() + (kernel / "Module.symvers").touch() + (kernel / ".config").write_text("CONFIG_KUNIT=y\n") + self.env = dict(os.environ, KDIR=str(kernel), SCST_LINT_KDIR=str(kernel), + PATH=str(self.bin), MAKEFLAGS="", MFLAGS="", MAKEOVERRIDES="", + GNUMAKEFLAGS="", FIXTURE_STATUS="0", FIXTURE_MAKE_TAIL_EXIT="0", + FIXTURE_OUTCOME="clean", FIXTURE_COVERAGE="full") + self.env.pop("GITHUB_STEP_SUMMARY", None) + driver = self.root / "scripts/sparse_smatch/smp.py" + driver.write_text('''import json, os, sys +from pathlib import Path +root = Path(__file__).resolve().parents[2] +assert os.environ['KUNIT_TEST'] == 'y' +assert os.environ['KUNIT_LABEL'] == 'ALL' +assert os.environ['DIFFBR'] == '' +assert os.environ['LINT_DEFER_REPORTS'] == '1' +status = int(os.environ['FIXTURE_STATUS']) +directory = root / '.cache/lint' +directory.mkdir(parents=True, exist_ok=True) +tool = sys.argv[1] +if os.environ.get('FIXTURE_NO_REPORT'): + sys.exit(2) +checked = [p + '/fixture.c' for p in ('scst', 'iscsi-scst', + 'qla2x00t-32gbit/qla2x00-target', 'srpt', 'fcst', 'scst_local')] +checked += ['scst/src/kunit/fixture.c'] +if tool in ('coccicheck', 'cppcheck'): + checked += ['usr/fileio/fixture.c', 'iscsi-scst/usr/fixture.c'] +if os.environ['FIXTURE_COVERAGE'] != 'full': + checked = checked[:1] +report = dict(tool=os.environ.get('FIXTURE_REPORT_TOOL', tool), run_id=os.environ['SCST_LINT_RUN_ID'], + outcome=os.environ['FIXTURE_OUTCOME'], exit_code=status, make_status=0, + errors=0, unchecked_files=[], unrecorded_checks=[], + translation_units=len(checked), checked_files=checked, + remaining=1 if status == 1 else 0) +report['rules'] = [dict(complete=True, native_status=0, failures=[], handled=checked)] +report['kernel_make_status'] = report['userspace_make_status'] = 0 +report['profiles'] = [dict(profile=profile, complete=True, native_status=0, failures=[], + translation_units=1, checked_files=checked) for profile in ('kernel', 'userspace')] +if os.environ.get('FIXTURE_NATIVE_INCOMPLETE'): + report['rules'][0]['complete'] = False + report['profiles'][0]['complete'] = False +if os.environ.get('FIXTURE_STALE'): + report['run_id'] = 'previous run' +(directory / (tool + '.json')).write_text(json.dumps(report)) +(directory / (tool + '.log')).write_text('raw diagnostic fixture\\n') +print('summary fixture') +sys.exit(status) +''') + + def run_ci(self, tool="sparse"): + return subprocess.run([sys.executable, "-B", str(self.root / "scripts/ci/run-analyzer.py"), + tool], cwd=self.root, env=self.env, + capture_output=True, text=True, timeout=10) + + def test_only_completed_findings_can_be_advisory(self): + for tool in ("sparse", "smatch", "coccicheck", "cppcheck"): + for status, outcome, expected in ( + (0, "clean", 0), (1, "findings", 0), (2, "incomplete", 2), + (1, "incomplete", 2), (0, "skipped", 2), (0, "findings", 2)): + with self.subTest(tool=tool, status=status, outcome=outcome): + self.env.update(FIXTURE_STATUS=str(status), FIXTURE_OUTCOME=outcome) + result = self.run_ci(tool) + self.assertEqual(result.returncode, expected, result.stdout + result.stderr) + self.assertEqual(result.stdout.count("raw diagnostic fixture"), 1) + self.assertEqual("::warning::" in result.stdout, + outcome == "findings" and expected == 0) + self.assertIn(f"make exit={2 if status else 0}", result.stdout) + + def test_make_failure_after_clean_analysis_cannot_pass(self): + self.env["FIXTURE_MAKE_TAIL_EXIT"] = "7" + result = self.run_ci() + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertIn("make exit=2, job exit=2", result.stdout) + + def test_basic_analyzers_cannot_downgrade_incomplete_native_reports(self): + self.env.update(FIXTURE_STATUS="1", FIXTURE_OUTCOME="findings", FIXTURE_NATIVE_INCOMPLETE="1") + for tool in ("coccicheck", "cppcheck"): + with self.subTest(tool=tool): + result = self.run_ci(tool) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertNotIn("::warning::", result.stdout) + + def test_make_failure_without_a_new_report_cannot_reuse_previous_results(self): + self.assertEqual(self.run_ci().returncode, 0) + self.env["FIXTURE_NO_REPORT"] = "1" + result = self.run_ci() + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertNotIn("raw diagnostic fixture", result.stdout) + + def test_report_from_another_tool_is_not_accepted(self): + self.env["FIXTURE_REPORT_TOOL"] = "smatch" + result = self.run_ci("sparse") + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertNotIn("raw diagnostic fixture", result.stdout) + + def test_missing_components_or_kunit_cannot_pass(self): + self.env["FIXTURE_COVERAGE"] = "partial" + result = self.run_ci() + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertIn("every module component and KUnit", result.stderr) + + def test_previous_report_is_not_printed_or_accepted(self): + self.env["FIXTURE_STALE"] = "1" + result = self.run_ci() + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertNotIn("raw diagnostic fixture", result.stdout) + + def test_failed_preparation_does_not_dump_old_logs(self): + self.assertEqual(self.run_ci().returncode, 0) + (self.root / "kernel/.config").write_text("# CONFIG_KUNIT is not set\n") + result = self.run_ci() + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertNotIn("raw diagnostic fixture", result.stdout) + + +if __name__ == "__main__": + unittest.main()