diff --git a/isvctl/configs/suites/storage.yaml b/isvctl/configs/suites/storage.yaml index 8600adc94..1e8413e15 100644 --- a/isvctl/configs/suites/storage.yaml +++ b/isvctl/configs/suites/storage.yaml @@ -472,6 +472,9 @@ tests: nfs_storage_class: "{{ steps.setup_cluster.csi.nfs_storage_class | default('', true) }}" image: "busybox:1.36" node_selector: {} + # Dev-sanity value, NOT the acceptance requirement. HSS07-02 / N-013a + # requires 1,000,000 files in a single directory (the check's own + # default); raise this per-NCP for a production validation run. files_count: 10000 pvc_size: "10Gi" bind_timeout_s: 120 @@ -485,6 +488,9 @@ tests: nfs_storage_class: "{{ steps.setup_cluster.csi.nfs_storage_class | default('', true) }}" image: "busybox:1.36" node_selector: {} + # Dev-sanity value, NOT the acceptance requirement. HSS07-02 / N-013b + # requires 500,000 subdirectories in a single directory (the check's + # own default); raise this per-NCP for a production validation run. dirs_count: 500 pvc_size: "10Gi" bind_timeout_s: 120 diff --git a/isvctl/src/isvctl/orchestrator/loop.py b/isvctl/src/isvctl/orchestrator/loop.py index 312bdd00c..1b242b603 100644 --- a/isvctl/src/isvctl/orchestrator/loop.py +++ b/isvctl/src/isvctl/orchestrator/loop.py @@ -29,6 +29,7 @@ from pathlib import Path from typing import Any +from isvtest.core.discovery import discover_all_tests from isvtest.core.resolution import ( DECLARABLE_CAPABILITIES, ErrorReason, @@ -340,6 +341,21 @@ def _apply_step_validation_gates(steps: list[Any], released_tests: set[str] | No return gated_steps +def _warn_unmet_prerequisites(entries: list[ResolvedEntry]) -> None: + """Warn about checks whose local prerequisites are unmet, before any of them runs. + + The checks still fail on their own terms when they execute; reporting here + means a run does not spend minutes on the checks ahead of them first. + """ + class_map = {cls.__name__: cls for cls in discover_all_tests()} + for entry in entries: + class_key = resolve_class_key(entry.entry.name, class_map) + if class_key is None: + continue + if unmet := class_map[class_key].preflight(entry.rendered_params or {}): + logger.warning("Validation '%s' will fail: %s", entry.entry.name, unmet) + + def _apply_capability_step_gates( steps: list[Any], validation_entries: list[ValidationEntry], @@ -674,6 +690,7 @@ def _run_steps_mode( ) ready_entries = [entry for entry in resolved_phase_entries if entry.is_ready] terminal_before_pytest = [entry for entry in resolved_phase_entries if not entry.is_ready] + _warn_unmet_prerequisites(ready_entries) # Write a per-phase stub for entries pre-resolved to SKIPPED/ERROR. # Suite name matches pytest's so the merger collapses them into one suite. diff --git a/isvctl/tests/test_orchestrator_loop.py b/isvctl/tests/test_orchestrator_loop.py index c0da2cb2c..062c00823 100644 --- a/isvctl/tests/test_orchestrator_loop.py +++ b/isvctl/tests/test_orchestrator_loop.py @@ -15,6 +15,7 @@ """Tests for orchestrator loop.""" +import logging import xml.etree.ElementTree as ET from pathlib import Path @@ -37,6 +38,7 @@ _apply_capability_step_gates, _entries_missing_from_junit, _merge_junit_xmls, + _warn_unmet_prerequisites, _write_terminal_junit_xml, ) from isvctl.orchestrator.step_executor import ( @@ -79,6 +81,44 @@ def test_explicit_step_requires_gate_unbound_lifecycle_steps() -> None: assert all(not step.skip for step in kubernetes_steps) +class TestWarnUnmetPrerequisites: + """A check's unmet local prerequisite is reported before the phase runs.""" + + def _entry(self, name: str, params: dict[str, str]) -> ResolvedEntry: + return ResolvedEntry( + entry=ValidationEntry(name=name, category="k8s_filesystem", params_template={}), + rendered_params=params, + ) + + def test_missing_vendored_pjdfstest_is_reported( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.setattr("isvtest.validations.k8s_filesystem._PJDFSTEST_SRC_DIR", tmp_path / "absent") + entries = [self._entry("K8sPosixComplianceCheck", {"shared_fs_storage_class": "sc-rwx"})] + + with caplog.at_level(logging.WARNING): + _warn_unmet_prerequisites(entries) + + assert "make vendor-pjdfstest" in caplog.text + + def test_satisfied_prerequisite_is_quiet( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.setattr("isvtest.validations.k8s_filesystem._PJDFSTEST_SRC_DIR", tmp_path) + entries = [self._entry("K8sPosixComplianceCheck", {"shared_fs_storage_class": "sc-rwx"})] + + with caplog.at_level(logging.WARNING): + _warn_unmet_prerequisites(entries) + + assert caplog.text == "" + + def test_unknown_validation_name_is_ignored(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + _warn_unmet_prerequisites([self._entry("NoSuchCheck", {})]) + + assert caplog.text == "" + + def test_python_script_path_falls_back_to_current_working_directory( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/isvtest/src/isvtest/core/validation.py b/isvtest/src/isvtest/core/validation.py index e65b31c4d..0568665f2 100644 --- a/isvtest/src/isvtest/core/validation.py +++ b/isvtest/src/isvtest/core/validation.py @@ -96,6 +96,21 @@ def run(self) -> None: """ pass + @classmethod + def preflight(cls, config: dict[str, Any]) -> str | None: + """Return why a local prerequisite is unmet, or None when runnable. + + The orchestrator evaluates this for every ready check before running a + phase's validations, so a missing local prerequisite (a vendored source + tree, a required binary) is reported up front instead of after every + check ahead of it has finished. The check must still enforce the same + prerequisite in ``run()``: this hook only reports, it does not gate. + Implementations must be cheap and local: no cluster calls, no network. + Return None when the prerequisite is satisfied, or when the check would + skip anyway for other reasons. + """ + return None + def run_command(self, cmd: str, timeout: int | None = None, display_cmd: str | None = None) -> CommandResult: """Run a command using the configured runner. diff --git a/isvtest/src/isvtest/validations/k8s_filesystem.py b/isvtest/src/isvtest/validations/k8s_filesystem.py index b4bd27d4b..a32d76246 100644 --- a/isvtest/src/isvtest/validations/k8s_filesystem.py +++ b/isvtest/src/isvtest/validations/k8s_filesystem.py @@ -136,6 +136,17 @@ def _fmt_err(text: str, max_len: int = 200) -> str: return text.strip()[:max_len] +def _shared_sc_from(config: dict[str, Any]) -> str: + """Resolve the RWX StorageClass: shared-fs, then NFS, then env fallbacks.""" + return str( + config.get("shared_fs_storage_class") + or config.get("nfs_storage_class") + or get_k8s_csi_shared_fs_storage_class() + or get_k8s_csi_nfs_storage_class() + or "" + ) + + # -------------------------------------------------------------------------- # Transport-neutral shell-snippet helpers. # @@ -189,14 +200,118 @@ def create_dirs_cmd(directory: str, count: int, prefix: str = "d") -> str: return f"mkdir -p {shlex.quote(directory)} && {names} | xargs mkdir" -def list_dir_quiet_cmd(directory: str) -> str: - """List ``directory`` discarding output; non-zero exit means ``ls`` errored.""" - return f"ls -1A {shlex.quote(directory)} >/dev/null" +def list_dir_to_file_cmd(directory: str, listing_path: str) -> str: + """List ``directory`` one entry per line into ``listing_path``. + + Saving the listing keeps the (expensive) directory scan separate from the + verification below, so a failing ``ls`` is reported as an ``ls`` error + rather than surfacing as thousands of missing names. + """ + return f"ls -1A {shlex.quote(directory)} > {shlex.quote(listing_path)}" + + +# awk program behind verify_listing_cmd: one pass over a saved listing, +# reconstructing each expected name (```` for n in 1..count) so +# names are compared, not just counted. sprintf("%d") avoids awk's CONVFMT +# rendering large indices in exponent form ("1e+06"). +_VERIFY_LISTING_AWK = """ +{ + total++ + i = substr($0, plen + 1) + 0 + if (i >= 1 && i <= n && $0 == p sprintf("%d", i)) { + if (i in seen) { duplicate++ } else { seen[i] = 1; matched++; next } + } else { + unexpected++ + } + if (bad < lim) { bad_samples = bad_samples " " $0; bad++ } +} +END { + print "total", total + 0 + print "matched", matched + 0 + print "missing", n - (matched + 0) + print "unexpected", unexpected + 0 + print "duplicate", duplicate + 0 + print "bad_samples" bad_samples + for (i = 1; i <= n && c < lim; i++) { + if (!(i in seen)) { ms = ms " " p sprintf("%d", i); c++ } + } + print "missing_samples" ms +} +""" + +# How many example names to report per kind of discrepancy. +_LISTING_SAMPLE_LIMIT = 5 + +def verify_listing_cmd(listing_path: str, count: int, prefix: str) -> str: + """Compare a saved listing against the ``1..count`` names created. -def count_entries_cmd(directory: str) -> str: - """Count immediate entries under ``directory`` (excludes ``.`` and ``..``).""" - return f"find {shlex.quote(directory)} -mindepth 1 -maxdepth 1 | wc -l" + Runs entirely where the listing lives, so verifying a million names costs + a handful of summary lines rather than streaming the listing back. Emits + `` `` lines for :func:`parse_listing_report`. + """ + return ( + f"awk -v n={int(count)} -v p={shlex.quote(prefix)} -v plen={len(prefix)} " + f"-v lim={_LISTING_SAMPLE_LIMIT} '{_VERIFY_LISTING_AWK}' {shlex.quote(listing_path)}" + ) + + +# -------------------------------------------------------------------------- +# Directory-listing verification output parsing. +# -------------------------------------------------------------------------- + +_LISTING_COUNT_KEYS = ("total", "matched", "missing", "unexpected", "duplicate") +_LISTING_SAMPLE_KEYS = ("bad_samples", "missing_samples") + + +@dataclass +class ListingReport: + """Parsed result of :func:`verify_listing_cmd`. + + ``error`` is set when the output could not be interpreted at all; otherwise + :attr:`intact` reports whether the listed names are exactly the created + ones. + """ + + total: int = 0 + matched: int = 0 + missing: int = 0 + unexpected: int = 0 + duplicate: int = 0 + # Example names, capped at _LISTING_SAMPLE_LIMIT each: created but not + # listed, and listed but unexpected or listed twice. + missing_samples: list[str] = field(default_factory=list) + bad_samples: list[str] = field(default_factory=list) + error: str = "" + + @property + def intact(self) -> bool: + """True when every created name was listed exactly once, and nothing else was.""" + return not (self.error or self.missing or self.unexpected or self.duplicate) + + +def parse_listing_report(output: str) -> ListingReport: + """Parse the `` `` summary emitted by :func:`verify_listing_cmd`.""" + counts: dict[str, int] = {} + samples: dict[str, list[str]] = {} + for line in output.splitlines(): + key, _, rest = line.strip().partition(" ") + if key in _LISTING_COUNT_KEYS: + try: + counts[key] = int(rest.strip()) + except ValueError: + continue + elif key in _LISTING_SAMPLE_KEYS: + samples[key] = rest.split() + + if missing_keys := [key for key in _LISTING_COUNT_KEYS if key not in counts]: + return ListingReport(error=f"Could not parse listing verification output (missing {missing_keys})") + + return ListingReport( + **counts, + missing_samples=samples.get("missing_samples", []), + bad_samples=samples.get("bad_samples", []), + ) # -------------------------------------------------------------------------- @@ -361,13 +476,7 @@ def _setup_kubectl(self) -> None: def _resolve_shared_sc(self) -> str: """Resolve the RWX StorageClass: shared-fs, then NFS, then env fallbacks.""" - return str( - self.config.get("shared_fs_storage_class") - or self.config.get("nfs_storage_class") - or get_k8s_csi_shared_fs_storage_class() - or get_k8s_csi_nfs_storage_class() - or "" - ) + return _shared_sc_from(self.config) def _create_namespace(self) -> bool: prefix = self.config.get("namespace_prefix", self._DEFAULT_NS_PREFIX) @@ -938,8 +1047,12 @@ def _parse_stat(result: CommandResult) -> tuple[int, int] | None: class _K8sLargeDirListingBase(_K8sSharedFsCheck): """Create many entries in one directory and list them without truncation. + The listing is then compared name by name against what was created, so a + filesystem that lists the right number of entries under the wrong names is + caught too. + Subclasses set :attr:`_ENTRY_KIND` (``files`` / ``dirs``), the config key - for the count, its default, and the creation snippet. + for the count, its default, the entry-name prefix, and the creation snippet. """ timeout: ClassVar[int] = 3600 @@ -949,6 +1062,7 @@ class _K8sLargeDirListingBase(_K8sSharedFsCheck): _ENTRY_KIND: ClassVar[str] = "" _COUNT_KEY: ClassVar[str] = "" _DEFAULT_COUNT: ClassVar[int] = 0 + _PREFIX: ClassVar[str] = "" @abstractmethod def _create_cmd(self, directory: str, count: int) -> str: @@ -1002,7 +1116,10 @@ def run(self) -> None: ) return - listing = self._exec(pod, list_dir_quiet_cmd(target_dir), timeout=self.timeout) + # Listing path is a sibling of target_dir so saving it cannot alter + # the directory under test. + listing_path = f"{_DATA_DIR}/bigdir-listing" + listing = self._exec(pod, list_dir_to_file_cmd(target_dir, listing_path), timeout=self.timeout) if listing.exit_code != 0: self.set_failed( f"ls of directory with {count} {self._ENTRY_KIND} errored: " @@ -1010,31 +1127,49 @@ def run(self) -> None: ) return - counted = self._exec(pod, count_entries_cmd(target_dir), timeout=self.timeout) - if counted.exit_code != 0: - self.set_failed(f"Counting entries failed: {_fmt_err(counted.stderr)}") + verified = self._exec(pod, verify_listing_cmd(listing_path, count, self._PREFIX), timeout=self.timeout) + if verified.exit_code != 0: + self.set_failed(f"Verifying the listing failed: {_fmt_err(verified.stderr or verified.stdout)}") return - try: - observed = int(counted.stdout.strip()) - except ValueError: - self.set_failed(f"Could not parse entry count: {_fmt_err(counted.stdout)!r}") + report = parse_listing_report(verified.stdout) + if report.error: + self.set_failed(f"{report.error}: {_fmt_err(verified.stdout)!r}") return - if observed == count: - self.set_passed(f"Listed all {count} {self._ENTRY_KIND} without error or truncation") + if report.intact: + self.set_passed( + f"Listed all {count} {self._ENTRY_KIND} without error or truncation, " + "and every listed name matched what was created" + ) else: self.set_failed( - f"Expected {count} {self._ENTRY_KIND} but listing found {observed} (possible truncation)" + f"Listing of {count} {self._ENTRY_KIND} did not match what was created: " + f"{self._describe_mismatch(report)}" ) finally: self._cleanup_namespace(created) + @staticmethod + def _describe_mismatch(report: ListingReport) -> str: + """Summarise a failed listing verification, naming a few offenders.""" + details = [f"listing held {report.total} entries, {report.matched} of which matched"] + if report.missing: + details.append(f"{report.missing} created but not listed (e.g. {', '.join(report.missing_samples)})") + if report.unexpected: + details.append(f"{report.unexpected} unexpected name(s) (e.g. {', '.join(report.bad_samples)})") + if report.duplicate: + details.append(f"{report.duplicate} name(s) listed more than once") + return "; ".join(details) + class K8sLargeDirListingFilesCheck(_K8sLargeDirListingBase): """List a directory holding a very large number of files. Config keys (with defaults): - files_count: Number of files to create (default: 1,000,000). + files_count: Number of files to create (default: 1,000,000 - the + acceptance requirement for HSS07-02 / N-013a). Suite wiring lowers + it for quick dev-sanity runs; a production validation must use the + full 1,000,000. shared_fs_storage_class / nfs_storage_class: RWX StorageClass; skipped when neither (nor the env fallbacks) is set. pvc_size: PVC request size (default: ``10Gi``). @@ -1049,16 +1184,20 @@ class K8sLargeDirListingFilesCheck(_K8sLargeDirListingBase): _ENTRY_KIND = "files" _COUNT_KEY = "files_count" _DEFAULT_COUNT = 1_000_000 + _PREFIX = "f" def _create_cmd(self, directory: str, count: int) -> str: - return create_files_cmd(directory, count, prefix="f") + return create_files_cmd(directory, count, prefix=self._PREFIX) class K8sLargeDirListingDirsCheck(_K8sLargeDirListingBase): """List a directory holding a very large number of subdirectories. Config keys (with defaults): - dirs_count: Number of subdirectories to create (default: 500,000). + dirs_count: Number of subdirectories to create (default: 500,000 - the + acceptance requirement for HSS07-02 / N-013b). Suite wiring lowers + it for quick dev-sanity runs; a production validation must use the + full 500,000. shared_fs_storage_class / nfs_storage_class: RWX StorageClass; skipped when neither (nor the env fallbacks) is set. pvc_size: PVC request size (default: ``10Gi``). @@ -1075,9 +1214,10 @@ class K8sLargeDirListingDirsCheck(_K8sLargeDirListingBase): _ENTRY_KIND = "subdirectories" _COUNT_KEY = "dirs_count" _DEFAULT_COUNT = 500_000 + _PREFIX = "d" def _create_cmd(self, directory: str, count: int) -> str: - return create_dirs_cmd(directory, count, prefix="d") + return create_dirs_cmd(directory, count, prefix=self._PREFIX) # -------------------------------------------------------------------------- @@ -1120,6 +1260,17 @@ class K8sPosixComplianceCheck(_K8sSharedFsCheck): _DEFAULT_PVC_SIZE = "5Gi" _DEFAULT_BIND_TIMEOUT_S = 300 + @classmethod + def preflight(cls, config: dict[str, Any]) -> str | None: + """Report a missing vendored pjdfstest tree before the run starts. + + Only reported when a StorageClass is configured; without one the check + skips and the vendored source is irrelevant. + """ + if not _shared_sc_from(config) or _PJDFSTEST_SRC_DIR.is_dir(): + return None + return f"Vendored pjdfstest source not found at {_PJDFSTEST_SRC_DIR}; run `make vendor-pjdfstest`" + @staticmethod def _is_podsecurity_denial(text: str) -> bool: """Return True when ``text`` looks like a Pod Security admission rejection.""" @@ -1162,8 +1313,8 @@ def run(self) -> None: if not sc: self.set_passed("Skipped: no shared-fs/nfs StorageClass configured") return - if not _PJDFSTEST_SRC_DIR.is_dir(): - self.set_failed(f"Vendored pjdfstest source not found at {_PJDFSTEST_SRC_DIR}; run `make vendor-pjdfstest`") + if unmet := self.preflight(self.config): + self.set_failed(unmet) return bind_timeout = int(self.config.get("bind_timeout_s", self._DEFAULT_BIND_TIMEOUT_S)) diff --git a/isvtest/tests/test_k8s_filesystem.py b/isvtest/tests/test_k8s_filesystem.py index 780e99c1b..6d1d854e6 100644 --- a/isvtest/tests/test_k8s_filesystem.py +++ b/isvtest/tests/test_k8s_filesystem.py @@ -19,7 +19,10 @@ import json import re +import shutil +import subprocess from contextlib import contextmanager +from pathlib import Path from typing import Any from unittest.mock import patch @@ -34,17 +37,19 @@ K8sFileLockingCheck, K8sLargeDirListingFilesCheck, K8sPosixComplianceCheck, + ListingReport, _set_fs_pod_fields, append_payload_cmd, - count_entries_cmd, create_dirs_cmd, create_files_cmd, flock_hold_command, flock_nonblock_cmd, - list_dir_quiet_cmd, + list_dir_to_file_cmd, + parse_listing_report, parse_pjdfstest_output, read_file_cmd, stat_size_mtime_cmd, + verify_listing_cmd, write_payload_cmd, ) @@ -156,9 +161,13 @@ def test_create_dirs_cmd(self) -> None: assert "seq 1 500" in cmd assert "xargs mkdir" in cmd - def test_list_and_count(self) -> None: - assert list_dir_quiet_cmd("/data/big") == "ls -1A /data/big >/dev/null" - assert count_entries_cmd("/data/big") == "find /data/big -mindepth 1 -maxdepth 1 | wc -l" + def test_list_dir_to_file_cmd(self) -> None: + assert list_dir_to_file_cmd("/data/big", "/data/listing") == "ls -1A /data/big > /data/listing" + + def test_verify_listing_cmd(self) -> None: + cmd = verify_listing_cmd("/data/listing", 1000, "f") + assert cmd.startswith("awk -v n=1000 -v p=f -v plen=1") + assert cmd.endswith("/data/listing") # -------------------------------------------------------------------------- @@ -471,33 +480,155 @@ def test_visible_within_window(self, monkeypatch: pytest.MonkeyPatch) -> None: assert check.passed, check._error -class TestLargeDirListingFlow: - def test_lists_all_entries(self, monkeypatch: pytest.MonkeyPatch) -> None: - _clear_sc_env(monkeypatch) - check = K8sLargeDirListingFilesCheck( - config={"shared_fs_storage_class": "sc-rwx", "files_count": 1000, "bind_timeout_s": 5} +def _report_lines( + *, + total: int, + matched: int, + missing: int = 0, + unexpected: int = 0, + duplicate: int = 0, + missing_samples: str = "", + bad_samples: str = "", +) -> str: + """Build the stdout that ``verify_listing_cmd`` produces in the pod.""" + return ( + f"total {total}\nmatched {matched}\nmissing {missing}\n" + f"unexpected {unexpected}\nduplicate {duplicate}\n" + f"bad_samples{bad_samples}\nmissing_samples{missing_samples}\n" + ) + + +class TestParseListingReport: + def test_parses_intact_listing(self) -> None: + report = parse_listing_report(_report_lines(total=1000, matched=1000)) + assert report.error == "" + assert report.intact + assert (report.total, report.matched) == (1000, 1000) + + def test_parses_discrepancies(self) -> None: + report = parse_listing_report( + _report_lines( + total=1000, + matched=998, + missing=2, + unexpected=1, + duplicate=1, + missing_samples=" f7 f9", + bad_samples=" stray f3", + ) ) + assert not report.intact + assert report.missing_samples == ["f7", "f9"] + assert report.bad_samples == ["stray", "f3"] + + def test_unparseable_output_sets_error(self) -> None: + report = parse_listing_report("awk: cannot open listing\n") + assert report.error + assert not report.intact + + +@pytest.mark.skipif(shutil.which("awk") is None, reason="awk not available") +class TestVerifyListingAwk: + """Exercise the real awk program behind ``verify_listing_cmd``.""" + + def _verify(self, tmp_path: Path, names: list[str], count: int, prefix: str = "f") -> ListingReport: + listing = tmp_path / "listing" + listing.write_text("".join(f"{name}\n" for name in names)) + completed = subprocess.run( + verify_listing_cmd(str(listing), count, prefix), + shell=True, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + return parse_listing_report(completed.stdout) + + def test_exact_match_is_intact(self, tmp_path: Path) -> None: + report = self._verify(tmp_path, [f"f{i}" for i in range(1, 11)], count=10) + assert report.intact, report + assert (report.total, report.matched) == (10, 10) + + def test_reports_missing_names(self, tmp_path: Path) -> None: + names = [f"f{i}" for i in range(1, 11) if i not in (3, 8)] + report = self._verify(tmp_path, names, count=10) + assert not report.intact + assert report.missing == 2 + assert report.missing_samples == ["f3", "f8"] + + def test_reports_unexpected_and_duplicate_names(self, tmp_path: Path) -> None: + names = [f"f{i}" for i in range(1, 11)] + ["stray", "f4", "f11"] + report = self._verify(tmp_path, names, count=10) + assert not report.intact + assert report.total == 13 + assert report.duplicate == 1 + # "stray" does not match the pattern, "f11" is out of range. + assert report.unexpected == 2 + assert report.bad_samples == ["stray", "f4", "f11"] + + def test_zero_padded_name_is_not_a_match(self, tmp_path: Path) -> None: + report = self._verify(tmp_path, ["f1", "f02", "f3"], count=3) + assert not report.intact + assert report.unexpected == 1 + assert report.missing_samples == ["f2"] + + def test_large_index_is_compared_exactly(self, tmp_path: Path) -> None: + # Guards the sprintf("%d") in the awk program: awk's default CONVFMT + # would render 1000000 as "1e+06" and never match the listed name. + report = self._verify(tmp_path, ["f1000000"], count=1_000_000) + assert report.matched == 1 + assert report.unexpected == 0 + + def test_directory_prefix(self, tmp_path: Path) -> None: + report = self._verify(tmp_path, ["d1", "d2"], count=2, prefix="d") + assert report.intact, report + +class TestLargeDirListingFlow: + def _router(self, verify_stdout: str) -> Any: def _side_effect(cmd: str, *args: Any, **kwargs: Any) -> CommandResult: - if "create namespace" in cmd or "delete namespace" in cmd: - return _ok() - if "wait --for=condition=Ready" in cmd: - return _ok() if "get pvc" in cmd: return _ok(stdout=_BOUND_PVC_JSON) - if "xargs touch" in cmd: - return _ok() - if "ls -1A" in cmd: - return _ok() - if "wc -l" in cmd: - return _ok(stdout="1000\n") + if "awk -v n=" in cmd: + return _ok(stdout=verify_stdout) return _ok() - with _patched_clock(), patch.object(check, "run_command", side_effect=_side_effect): + return _side_effect + + def _run(self, verify_stdout: str) -> K8sLargeDirListingFilesCheck: + check = K8sLargeDirListingFilesCheck( + config={"shared_fs_storage_class": "sc-rwx", "files_count": 1000, "bind_timeout_s": 5} + ) + with _patched_clock(), patch.object(check, "run_command", side_effect=self._router(verify_stdout)): check.run() + return check + + def test_lists_all_entries(self, monkeypatch: pytest.MonkeyPatch) -> None: + _clear_sc_env(monkeypatch) + check = self._run(_report_lines(total=1000, matched=1000)) assert check.passed, check._error def test_truncated_listing_fails(self, monkeypatch: pytest.MonkeyPatch) -> None: + _clear_sc_env(monkeypatch) + check = self._run(_report_lines(total=999, matched=999, missing=1, missing_samples=" f42")) + assert not check.passed + assert "f42" in check._error + + def test_mismatched_names_fail_despite_correct_count(self, monkeypatch: pytest.MonkeyPatch) -> None: + _clear_sc_env(monkeypatch) + check = self._run( + _report_lines( + total=1000, + matched=999, + missing=1, + unexpected=1, + missing_samples=" f42", + bad_samples=" bogus", + ) + ) + assert not check.passed + assert "bogus" in check._error and "f42" in check._error + + def test_ls_failure_is_reported_as_such(self, monkeypatch: pytest.MonkeyPatch) -> None: _clear_sc_env(monkeypatch) check = K8sLargeDirListingFilesCheck( config={"shared_fs_storage_class": "sc-rwx", "files_count": 1000, "bind_timeout_s": 5} @@ -506,14 +637,14 @@ def test_truncated_listing_fails(self, monkeypatch: pytest.MonkeyPatch) -> None: def _side_effect(cmd: str, *args: Any, **kwargs: Any) -> CommandResult: if "get pvc" in cmd: return _ok(stdout=_BOUND_PVC_JSON) - if "wc -l" in cmd: - return _ok(stdout="999\n") # one short - truncation + if "ls -1A" in cmd: + return _fail(stderr="ls: value too large for defined data type") return _ok() with _patched_clock(), patch.object(check, "run_command", side_effect=_side_effect): check.run() assert not check.passed - assert "truncation" in check._error + assert "ls of directory" in check._error # -------------------------------------------------------------------------- @@ -650,6 +781,38 @@ def test_podsecurity_denial_skips(self, tmp_path: Any, monkeypatch: pytest.Monke assert check.passed assert "Skipped" in check._output + def test_preflight_reports_missing_vendored_source(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _clear_sc_env(monkeypatch) + monkeypatch.setattr("isvtest.validations.k8s_filesystem._PJDFSTEST_SRC_DIR", tmp_path / "absent") + unmet = K8sPosixComplianceCheck.preflight({"shared_fs_storage_class": "sc-rwx"}) + assert unmet is not None + assert "make vendor-pjdfstest" in unmet + + def test_preflight_quiet_when_vendored_source_present( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + _clear_sc_env(monkeypatch) + monkeypatch.setattr("isvtest.validations.k8s_filesystem._PJDFSTEST_SRC_DIR", tmp_path) + assert K8sPosixComplianceCheck.preflight({"shared_fs_storage_class": "sc-rwx"}) is None + + def test_preflight_quiet_without_storage_class(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + # No StorageClass means the check skips, so the vendored tree is moot. + _clear_sc_env(monkeypatch) + monkeypatch.setattr("isvtest.validations.k8s_filesystem._PJDFSTEST_SRC_DIR", tmp_path / "absent") + assert K8sPosixComplianceCheck.preflight({}) is None + + def test_missing_vendored_source_fails_before_any_command( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + _clear_sc_env(monkeypatch) + monkeypatch.setattr("isvtest.validations.k8s_filesystem._PJDFSTEST_SRC_DIR", tmp_path / "absent") + check = K8sPosixComplianceCheck(config={"shared_fs_storage_class": "sc-rwx"}) + with patch.object(check, "run_command") as mock_run: + check.run() + mock_run.assert_not_called() + assert not check.passed + assert "make vendor-pjdfstest" in check._error + def test_is_podsecurity_denial_detection(self) -> None: assert K8sPosixComplianceCheck._is_podsecurity_denial("violates PodSecurity ...") assert K8sPosixComplianceCheck._is_podsecurity_denial("privileged is forbidden")