From d14d2fb239ae4972920d0f96668c75ef02c47155 Mon Sep 17 00:00:00 2001 From: Mresyzz Date: Sun, 8 Nov 2026 13:16:44 +0800 Subject: [PATCH] feat: add exit 126 and OOM diagnostics, Amazon/Oracle Linux support, action outputs, and bump to v0.9.5 --- CHANGELOG.md | 9 ++ action.yml | 18 ++- pyproject.toml | 2 +- src/opsscript_gate/__init__.py | 2 +- src/opsscript_gate/config.py | 2 +- src/opsscript_gate/models.py | 24 +++ src/opsscript_gate/remediation.py | 69 ++++++--- src/opsscript_gate/reporter.py | 24 ++- src/opsscript_gate/runner.py | 238 ++++++++++++++++++++++-------- tests/test_action_contract.py | 3 + tests/test_audit_coverage.py | 146 ++++++++++++++++++ 11 files changed, 447 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c47c1f9..1ba6ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [0.9.5] - 2026-11-08 + +### Added +- Extended structured failure diagnostics to handle exit code 126 (`permission_denied` and `exec_format_error`) and exit code 137 (`oom_killed`) with targeted remediation suggestions. +- Added native distribution family detection for Amazon Linux (`amazonlinux`, `amzn`) and Oracle Linux (`oracle`, `ol:`) to support RHEL-family package recommendations. +- Added GitHub Composite Action `outputs` for `all-passed` and `report-file`, exporting execution status via `$GITHUB_OUTPUT`. +- Surfaced package installation recommendations and package names directly in Markdown compatibility tables, failure diagnostic log drawers, and terminal detail outputs. +- Added convenient counting properties (`passed_count`, `failed_count`, `total_count`) on `RunReport` and `MultiScriptReport`. + ## [0.9.4] - 2026-10-11 ### Fixed diff --git a/action.yml b/action.yml index b0001fc..b3f890f 100644 --- a/action.yml +++ b/action.yml @@ -80,6 +80,14 @@ inputs: required: false default: '' +outputs: + all-passed: + description: 'Whether all script compatibility checks passed (true or false)' + value: ${{ steps.gate.outputs.all-passed }} + report-file: + description: 'Path to saved report file if output input was set' + value: ${{ inputs.output }} + runs: using: 'composite' steps: @@ -94,6 +102,7 @@ runs: pip install "${{ github.action_path }}" - name: Run compatibility gate + id: gate shell: bash env: INPUT_SCRIPT_PATH: ${{ inputs.script-path }} @@ -146,4 +155,11 @@ runs: echo "dry-run must be true or false" >&2 exit 1 fi - python -m opsscript_gate.cli "${ARGS[@]}" + GATE_STATUS=0 + python -m opsscript_gate.cli "${ARGS[@]}" || GATE_STATUS=$? + if [ "$GATE_STATUS" -eq 0 ]; then + echo "all-passed=true" >> "$GITHUB_OUTPUT" + else + echo "all-passed=false" >> "$GITHUB_OUTPUT" + fi + exit "$GATE_STATUS" diff --git a/pyproject.toml b/pyproject.toml index c3cf144..c410891 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "opsscript-gate" -version = "0.9.4" +version = "0.9.5" description = "Cross-distro shell script compatibility and portability checker for Debian, Ubuntu, and Alpine. Runs real containers to diagnose command not found." readme = "README.md" license = { text = "MIT" } diff --git a/src/opsscript_gate/__init__.py b/src/opsscript_gate/__init__.py index 5385efc..8564f66 100644 --- a/src/opsscript_gate/__init__.py +++ b/src/opsscript_gate/__init__.py @@ -1,4 +1,4 @@ """OpsScript Gate: Lightweight cross-distro compatibility pre-check for Linux ops scripts.""" -__version__ = "0.9.4" +__version__ = "0.9.5" __all__ = ["__version__"] diff --git a/src/opsscript_gate/config.py b/src/opsscript_gate/config.py index 94362ee..e681cd4 100644 --- a/src/opsscript_gate/config.py +++ b/src/opsscript_gate/config.py @@ -126,7 +126,7 @@ def init_project() -> list[str]: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: Mresyzz/opsscript-gate@v0.9.4 + - uses: Mresyzz/opsscript-gate@v0.9.5 with: config: .opsscript-gate.json """, diff --git a/src/opsscript_gate/models.py b/src/opsscript_gate/models.py index 763c698..cb8997a 100644 --- a/src/opsscript_gate/models.py +++ b/src/opsscript_gate/models.py @@ -114,6 +114,18 @@ def __post_init__(self) -> None: if self.results: self.all_passed = all(r.status == DistroStatus.PASS for r in self.results) + @property + def passed_count(self) -> int: + return sum(1 for r in self.results if r.status == DistroStatus.PASS) + + @property + def failed_count(self) -> int: + return len(self.results) - self.passed_count + + @property + def total_count(self) -> int: + return len(self.results) + def to_dict(self) -> dict[str, Any]: return { "results": [r.to_dict() for r in self.results], @@ -133,6 +145,18 @@ def __post_init__(self) -> None: if self.reports: self.all_passed = all(r.all_passed for r in self.reports.values()) + @property + def passed_scripts_count(self) -> int: + return sum(1 for r in self.reports.values() if r.all_passed) + + @property + def failed_scripts_count(self) -> int: + return len(self.reports) - self.passed_scripts_count + + @property + def total_scripts_count(self) -> int: + return len(self.reports) + def to_dict(self) -> dict[str, Any]: return { "reports": {script: r.to_dict() for script, r in self.reports.items()}, diff --git a/src/opsscript_gate/remediation.py b/src/opsscript_gate/remediation.py index f646423..0c801c6 100644 --- a/src/opsscript_gate/remediation.py +++ b/src/opsscript_gate/remediation.py @@ -22,38 +22,48 @@ def _normalize_cmd_basename(command: str | None) -> str: return norm +def detect_distro_family(distro: str | None) -> str: + """Identify the distribution family for package manager commands.""" + if not distro: + return "unknown" + d = distro.lower() + if "alpine" in d: + return "alpine" + if any(name in d for name in ("debian", "ubuntu", "mint")): + return "debian" + if any(name in d for name in ("fedora", "centos", "rhel", "redhat", "rocky", "alma", "amazonlinux", "amzn", "oracle", "ol:")): + return "rhel" + return "unknown" + + def _match_alpine_apt(distro: str | None, command: str | None, kind: str) -> bool: if not distro or not command: return False - return "alpine" in distro.lower() and _normalize_cmd_basename(command) in ("apt", "apt-get") + return detect_distro_family(distro) == "alpine" and _normalize_cmd_basename(command) in ("apt", "apt-get") def _match_debian_ubuntu_apk(distro: str | None, command: str | None, kind: str) -> bool: if not distro or not command: return False - d = distro.lower() - return ("debian" in d or "ubuntu" in d) and _normalize_cmd_basename(command) == "apk" + return detect_distro_family(distro) == "debian" and _normalize_cmd_basename(command) == "apk" def _match_debian_ubuntu_yum(distro: str | None, command: str | None, kind: str) -> bool: if not distro or not command: return False - d = distro.lower() - return ("debian" in d or "ubuntu" in d) and _normalize_cmd_basename(command) in ("yum", "dnf") + return detect_distro_family(distro) == "debian" and _normalize_cmd_basename(command) in ("yum", "dnf") def _match_alpine_yum(distro: str | None, command: str | None, kind: str) -> bool: if not distro or not command: return False - return "alpine" in distro.lower() and _normalize_cmd_basename(command) in ("yum", "dnf") + return detect_distro_family(distro) == "alpine" and _normalize_cmd_basename(command) in ("yum", "dnf") def _match_rhel_fedora_apt(distro: str | None, command: str | None, kind: str) -> bool: if not distro or not command: return False - d = distro.lower() - is_rhel = any(name in d for name in ("fedora", "centos", "rhel", "redhat", "rocky", "alma")) - return is_rhel and _normalize_cmd_basename(command) in ("apt", "apt-get", "apk") + return detect_distro_family(distro) == "rhel" and _normalize_cmd_basename(command) in ("apt", "apt-get", "apk") def _match_alpine_bash(distro: str | None, command: str | None, kind: str) -> bool: @@ -116,18 +126,16 @@ def _match_interpreter(distro: str | None, command: str | None, kind: str) -> bo } -def detect_distro_family(distro: str | None) -> str: - """Identify the distribution family for package manager commands.""" - if not distro: - return "unknown" - d = distro.lower() - if "alpine" in d: - return "alpine" - if any(name in d for name in ("debian", "ubuntu", "mint")): - return "debian" - if any(name in d for name in ("fedora", "centos", "rhel", "redhat", "rocky", "alma")): - return "rhel" - return "unknown" +def _match_permission_denied(distro: str | None, command: str | None, kind: str) -> bool: + return kind == "permission_denied" + + +def _match_exec_format(distro: str | None, command: str | None, kind: str) -> bool: + return kind == "exec_format_error" + + +def _match_oom_killed(distro: str | None, command: str | None, kind: str) -> bool: + return kind == "oom_killed" def resolve_package_recommendation( @@ -140,9 +148,7 @@ def resolve_package_recommendation( """ if not command: return None, None - norm_cmd = command.lower().replace("\\", "/").split("/")[-1] - if norm_cmd.endswith(".exe"): - norm_cmd = norm_cmd[:-4] + norm_cmd = _normalize_cmd_basename(command) packages = COMMON_COMMAND_PACKAGES.get(norm_cmd) if not packages: @@ -169,6 +175,21 @@ def resolve_package_recommendation( REMEDIATION_RULES: list[RemediationRule] = [ + RemediationRule( + rule_id="permission_denied", + matcher=_match_permission_denied, + hint="Target script or binary lacks executable permissions; consider running 'chmod +x ' or executing via an explicit interpreter (e.g. 'sh script.sh').", + ), + RemediationRule( + rule_id="exec_format_error", + matcher=_match_exec_format, + hint="Binary format is incompatible with the container architecture or C runtime (e.g. glibc binary on Alpine musl); consider using native packages or statically linked binaries.", + ), + RemediationRule( + rule_id="oom_killed", + matcher=_match_oom_killed, + hint="Container exceeded configured memory limit and was killed by OOM killer; consider optimizing memory usage or increasing --mem-limit.", + ), RemediationRule( rule_id="alpine_apt", matcher=_match_alpine_apt, diff --git a/src/opsscript_gate/reporter.py b/src/opsscript_gate/reporter.py index 10e7e7a..31e1f0b 100644 --- a/src/opsscript_gate/reporter.py +++ b/src/opsscript_gate/reporter.py @@ -264,7 +264,10 @@ def format_terminal_table(report: RunReport, script_path: str = "") -> str: line_suffix = f" (line {r.diagnostic.line})" if r.diagnostic.line else "" detail = f"{sanitize_display_text(r.diagnostic.message)}{line_suffix}" if r.diagnostic.hint: - hints.append((safe_distro, sanitize_display_text(r.diagnostic.hint))) + hint_text = sanitize_display_text(r.diagnostic.hint) + if r.diagnostic.suggested_package: + hint_text += f" (suggested package: {sanitize_display_text(r.diagnostic.suggested_package)})" + hints.append((safe_distro, hint_text)) elif r.error_message: detail = sanitize_display_text(r.error_message) else: @@ -403,9 +406,18 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "") elif r.diagnostic: line_str = f" (line {r.diagnostic.line})" if r.diagnostic.line else "" clean_cmd = _table_code_span(r.diagnostic.command) if r.diagnostic.command else "command" - diag_cell = f"⚠️ Missing command: {clean_cmd}{line_str}" + if r.diagnostic.kind == "permission_denied": + diag_cell = f"Permission denied: {clean_cmd}{line_str}" + elif r.diagnostic.kind == "exec_format_error": + diag_cell = f"Exec format error: {clean_cmd}{line_str}" + elif r.diagnostic.kind == "oom_killed": + diag_cell = "OOM Killed (memory limit exceeded)" + else: + diag_cell = f"Missing command: {clean_cmd}{line_str}" + if r.diagnostic.suggested_package: + diag_cell += f"
Package: `{escape_markdown_table_cell(r.diagnostic.suggested_package)}`" if r.diagnostic.hint: - diag_cell += f"
💡 *{escape_markdown_table_cell(r.diagnostic.hint)}*" + diag_cell += f"
*{escape_markdown_table_cell(r.diagnostic.hint)}*" elif r.error_message: diag_cell = escape_markdown_table_cell(r.error_message) elif r.status != DistroStatus.PASS: @@ -466,9 +478,12 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "") clean_msg = escape_inline_code(r.diagnostic.message) line_info = f" (line {r.diagnostic.line})" if r.diagnostic.line else "" lines.append(f"> **Diagnostic:** `{clean_kind}` — `{clean_msg}`{line_info}") + if r.diagnostic.suggested_package: + clean_pkg = escape_inline_code(r.diagnostic.suggested_package) + lines.append(f"> **Suggested Package:** `{clean_pkg}`") if r.diagnostic.hint: safe_hint = escape_markdown_text(r.diagnostic.hint) - lines.append(f"> 💡 **Recommendation:** {safe_hint}") + lines.append(f"> **Recommendation:** {safe_hint}") lines.append("") if r.error_message: safe_err = escape_markdown_text(r.error_message) @@ -583,6 +598,7 @@ def _sarif_result(result: SingleResult, script_path: str) -> dict[str, object] | if result.exit_code is not None: properties["exitCode"] = result.exit_code if result.diagnostic: + properties["diagnosticKind"] = result.diagnostic.kind if result.diagnostic.command: properties["command"] = sanitize_display_text(result.diagnostic.command) if result.diagnostic.suggested_package: diff --git a/src/opsscript_gate/runner.py b/src/opsscript_gate/runner.py index 664f5c3..9e034bb 100644 --- a/src/opsscript_gate/runner.py +++ b/src/opsscript_gate/runner.py @@ -168,6 +168,56 @@ def sanitize_diagnostic_text(text: str, max_length: int = 200) -> str: re.IGNORECASE | re.VERBOSE | re.MULTILINE, ) +# Regular expression matching shell-origin 'permission denied' error patterns (exit code 126): +_PERMISSION_DENIED_RE = re.compile( + r""" + (?:^|(?<=[\r\n])) + \s* + (?: + (?: + (?:/(?:usr/)?bin/)?(?:sh|bash|dash|ash) + (?::\s*[^:\r\n]+)? + | + [^:\r\n]+? + ) + :\s*(?:line\s+(?P\d+)|(?P\d+)) + | + (?:/(?:usr/)?bin/)?(?:sh|bash|dash|ash) + ) + :\s+ + (?P['"`]?[\w./+-]+['"`]?) + :\s+ + (?:Permission\s+denied) + \s*(?=$|[\r\n]) + """, + re.IGNORECASE | re.VERBOSE | re.MULTILINE, +) + +# Regular expression matching binary execution format error patterns (exit code 126): +_EXEC_FORMAT_RE = re.compile( + r""" + (?:^|(?<=[\r\n])) + \s* + (?: + (?: + (?:/(?:usr/)?bin/)?(?:sh|bash|dash|ash) + (?::\s*[^:\r\n]+)? + | + [^:\r\n]+? + ) + :\s*(?:line\s+(?P\d+)|(?P\d+)) + | + (?:/(?:usr/)?bin/)?(?:sh|bash|dash|ash) + ) + :\s+ + (?P['"`]?[\w./+-]+['"`]?) + :\s+ + (?:cannot\s+execute\s+binary\s+file(?::\s+Exec\s+format\s+error)?|Exec\s+format\s+error) + \s*(?=$|[\r\n]) + """, + re.IGNORECASE | re.VERBOSE | re.MULTILINE, +) + # Strict whitelist for extracted command token validation (alphanumerics, Unicode words, dots, dashes, slashes): _VALID_COMMAND_TOKEN_RE = re.compile(r"^[\w./+-]+$") @@ -188,74 +238,134 @@ def extract_failure_diagnostic( Extract high-confidence structured diagnostic from container execution failure. Deliberately conservative (prefer false negatives over false positives): - - Requires exit_code == 127 as an essential high-confidence signal. - - Requires matching a well-known shell 'not found' pattern in output. - - Strictly validates command token syntax (no arbitrary text or markdown injection). + - Supports exit_code == 127 (missing command). + - Supports exit_code == 126 (permission denied or exec format error). + - Strictly validates command/target token syntax (no arbitrary text or injection). - Extracts script line number if reliably present, otherwise None (never guess). - Applies conservative remediation rules when a recognized pattern matches. - - Returns None if not confidently classified as missing_command. """ - if exit_code != 127 or not output: + if exit_code not in (127, 126) or not output: return None # Strip ANSI escape sequences first cleaned_output = _ANSI_ESCAPE_RE.sub("", output) - for line in cleaned_output.splitlines(): - line_clean = line.strip() - if not line_clean: - continue - - m = _MISSING_COMMAND_RE.search(line_clean) - if not m: - continue - - raw_cmd = m.group("cmd").strip("'\"`") - if not raw_cmd or raw_cmd.isdigit(): - continue - - # Disallow pure dot/slash sequences - if raw_cmd in (".", "..", "/", "//"): - continue - - # Exclude common non-command tokens and protocol prefixes (e.g. Status, Error, HTTP/1.1) - lowered = raw_cmd.lower() - if lowered in _NON_COMMAND_TOKENS or lowered.split("/")[0] in _NON_COMMAND_TOKENS: - continue - - # Validate against strict conservative grammar - if not _VALID_COMMAND_TOKEN_RE.match(raw_cmd): - continue - - sanitized_cmd = sanitize_diagnostic_text(raw_cmd, max_length=100) - if not sanitized_cmd or not _VALID_COMMAND_TOKEN_RE.match(sanitized_cmd): - continue - - line_str = m.group("line1") or m.group("line2") - # Avoid converting attacker-controlled arbitrarily long digit strings. - line_num = None - if line_str and len(line_str) <= 9 and line_str.isdigit(): - try: - line_num = int(line_str) - except ValueError: - line_num = None + if exit_code == 127: + for line in cleaned_output.splitlines(): + line_clean = line.strip() + if not line_clean: + continue + + m = _MISSING_COMMAND_RE.search(line_clean) + if not m: + continue + + raw_cmd = m.group("cmd").strip("'\"`") + if not raw_cmd or raw_cmd.isdigit(): + continue + + # Disallow pure dot/slash sequences + if raw_cmd in (".", "..", "/", "//"): + continue + + # Exclude common non-command tokens and protocol prefixes (e.g. Status, Error, HTTP/1.1) + lowered = raw_cmd.lower() + if lowered in _NON_COMMAND_TOKENS or lowered.split("/")[0] in _NON_COMMAND_TOKENS: + continue + + # Validate against strict conservative grammar + if not _VALID_COMMAND_TOKEN_RE.match(raw_cmd): + continue + + sanitized_cmd = sanitize_diagnostic_text(raw_cmd, max_length=100) + if not sanitized_cmd or not _VALID_COMMAND_TOKEN_RE.match(sanitized_cmd): + continue + + line_str = m.group("line1") or m.group("line2") + # Avoid converting attacker-controlled arbitrarily long digit strings. + line_num = None + if line_str and len(line_str) <= 9 and line_str.isdigit(): + try: + line_num = int(line_str) + except ValueError: + line_num = None + + hint = generate_remediation_hint( + distro=distro, + command=sanitized_cmd, + kind="missing_command", + ) + suggested_pkg, _ = resolve_package_recommendation(sanitized_cmd, distro) - hint = generate_remediation_hint( - distro=distro, - command=sanitized_cmd, - kind="missing_command", - ) - suggested_pkg, _ = resolve_package_recommendation(sanitized_cmd, distro) + return FailureDiagnostic( + kind="missing_command", + command=sanitized_cmd, + message=f"command not found: {sanitized_cmd}", + line=line_num, + distro=distro, + hint=hint, + suggested_package=suggested_pkg, + ) - return FailureDiagnostic( - kind="missing_command", - command=sanitized_cmd, - message=f"command not found: {sanitized_cmd}", - line=line_num, - distro=distro, - hint=hint, - suggested_package=suggested_pkg, - ) + elif exit_code == 126: + for line in cleaned_output.splitlines(): + line_clean = line.strip() + if not line_clean: + continue + + # 1. Check for permission denied on script or binary + m_perm = _PERMISSION_DENIED_RE.search(line_clean) + if m_perm: + raw_target = m_perm.group("cmd").strip("'\"`") + if raw_target and raw_target not in (".", "..", "/", "//") and _VALID_COMMAND_TOKEN_RE.match(raw_target): + sanitized_target = sanitize_diagnostic_text(raw_target, max_length=100) + line_str = m_perm.group("line1") or m_perm.group("line2") + line_num = None + if line_str and len(line_str) <= 9 and line_str.isdigit(): + try: + line_num = int(line_str) + except ValueError: + line_num = None + hint = generate_remediation_hint( + distro=distro, + command=sanitized_target, + kind="permission_denied", + ) + return FailureDiagnostic( + kind="permission_denied", + command=sanitized_target, + message=f"permission denied: {sanitized_target}", + line=line_num, + distro=distro, + hint=hint, + ) + + # 2. Check for exec format error / cannot execute binary + m_exec = _EXEC_FORMAT_RE.search(line_clean) + if m_exec: + raw_target = m_exec.group("cmd").strip("'\"`") + if raw_target and raw_target not in (".", "..", "/", "//") and _VALID_COMMAND_TOKEN_RE.match(raw_target): + sanitized_target = sanitize_diagnostic_text(raw_target, max_length=100) + line_str = m_exec.group("line1") or m_exec.group("line2") + line_num = None + if line_str and len(line_str) <= 9 and line_str.isdigit(): + try: + line_num = int(line_str) + except ValueError: + line_num = None + hint = generate_remediation_hint( + distro=distro, + command=sanitized_target, + kind="exec_format_error", + ) + return FailureDiagnostic( + kind="exec_format_error", + command=sanitized_target, + message=f"cannot execute binary: {sanitized_target}", + line=line_num, + distro=distro, + hint=hint, + ) return None @@ -1016,6 +1126,18 @@ def run_on_distro( diagnostic = extract_failure_diagnostic(output, exit_code=exit_code, distro=distro) if state.get("OOMKilled"): err_msg = f"Container exceeded memory limit ({mem_limit}) and was killed by OOM killer (exit code: {exit_code})" + if diagnostic is None: + hint = generate_remediation_hint( + distro=distro, + command=None, + kind="oom_killed", + ) + diagnostic = FailureDiagnostic( + kind="oom_killed", + message=f"container exceeded memory limit ({mem_limit})", + distro=distro, + hint=hint, + ) else: err_msg = f"Script failed with non-zero exit code: {exit_code}" return SingleResult( diff --git a/tests/test_action_contract.py b/tests/test_action_contract.py index 2f033a4..296297e 100644 --- a/tests/test_action_contract.py +++ b/tests/test_action_contract.py @@ -19,6 +19,9 @@ def test_action_metadata_is_valid_and_inputs_are_wired(): assert "json, or sarif" in text assert "--changed-since" in run_text assert "DRY_RUN_VALUE=\"$(printf '%s' \"$INPUT_DRY_RUN\" | tr '[:upper:]' '[:lower:]')\"" in text + assert "outputs:" in text + assert " all-passed:" in text + assert " report-file:" in text def test_test_workflow_has_valid_yaml_and_python_indent(): diff --git a/tests/test_audit_coverage.py b/tests/test_audit_coverage.py index 93063b1..c50f96a 100644 --- a/tests/test_audit_coverage.py +++ b/tests/test_audit_coverage.py @@ -575,3 +575,149 @@ def test_discovery_matches_exclude_directory(): assert _matches_exclude("tests/foo.sh", ["tests/"]) is True assert _matches_exclude("tests/foo.sh", ["./tests/"]) is True assert _matches_exclude("other/foo.sh", ["tests"]) is False + + +# ============================================================================== +# 8. 扩展诊断引擎与生态增强测试(Exit 126、OOM、Amazon/Oracle Linux、Outputs) +# ============================================================================== + +def test_extract_failure_diagnostic_permission_denied(): + from opsscript_gate.runner import extract_failure_diagnostic + + # Bash 格式: bash: ./install.sh: Permission denied + out_bash = "bash: ./install.sh: Permission denied\n" + diag_bash = extract_failure_diagnostic(out_bash, exit_code=126, distro="debian:12-slim") + assert diag_bash is not None + assert diag_bash.kind == "permission_denied" + assert diag_bash.command == "./install.sh" + assert "permission denied" in diag_bash.message + assert "chmod +x" in diag_bash.hint + + # Dash 格式带行号: sh: 1: ./install.sh: Permission denied + out_dash = "sh: 1: ./install.sh: Permission denied\n" + diag_dash = extract_failure_diagnostic(out_dash, exit_code=126, distro="ubuntu:24.04") + assert diag_dash is not None + assert diag_dash.kind == "permission_denied" + assert diag_dash.command == "./install.sh" + assert diag_dash.line == 1 + + # Ash 格式: /bin/sh: line 3: ./helper.sh: Permission denied + out_ash = "/bin/sh: line 3: ./helper.sh: Permission denied\n" + diag_ash = extract_failure_diagnostic(out_ash, exit_code=126, distro="alpine:3.20") + assert diag_ash is not None + assert diag_ash.kind == "permission_denied" + assert diag_ash.command == "./helper.sh" + assert diag_ash.line == 3 + + # 非 126 退出码不会误报 permission_denied + assert extract_failure_diagnostic(out_bash, exit_code=1, distro="debian:12-slim") is None + + +def test_extract_failure_diagnostic_exec_format_error(): + from opsscript_gate.runner import extract_failure_diagnostic + + # Bash 格式: bash: ./binary: cannot execute binary file: Exec format error + out_bash = "bash: ./binary: cannot execute binary file: Exec format error\n" + diag = extract_failure_diagnostic(out_bash, exit_code=126, distro="alpine:3.20") + assert diag is not None + assert diag.kind == "exec_format_error" + assert diag.command == "./binary" + assert "cannot execute binary" in diag.message + assert "incompatible" in diag.hint + + # Dash 格式: sh: 1: ./binary: Exec format error + out_dash = "sh: 1: ./binary: Exec format error\n" + diag_dash = extract_failure_diagnostic(out_dash, exit_code=126, distro="ubuntu:22.04") + assert diag_dash is not None + assert diag_dash.kind == "exec_format_error" + assert diag_dash.line == 1 + + +def test_remediation_amazon_and_oracle_linux(): + from opsscript_gate.remediation import detect_distro_family, resolve_package_recommendation, generate_remediation_hint + + # 验证 Amazon Linux + assert detect_distro_family("amazonlinux:2023") == "rhel" + assert detect_distro_family("amazonlinux:2") == "rhel" + assert detect_distro_family("public.ecr.aws/amazonlinux/amazonlinux:2023") == "rhel" + + # 验证 Oracle Linux + assert detect_distro_family("oraclelinux:9") == "rhel" + assert detect_distro_family("ol:8") == "rhel" + + # 验证在 Amazon Linux 下的包推荐 + pkg, cmd = resolve_package_recommendation("curl", "amazonlinux:2023") + assert pkg == "curl" + assert cmd == "dnf install -y curl" + + # 验证在 Amazon Linux 误用 apt-get 的提示 + hint = generate_remediation_hint(distro="amazonlinux:2023", command="apt-get", kind="missing_command") + assert "RHEL/Fedora family distributions normally use dnf or yum" in hint + + +def test_run_report_count_properties(): + from opsscript_gate.models import RunReport, MultiScriptReport, SingleResult, DistroStatus + + res_pass = SingleResult("debian:12-slim", DistroStatus.PASS, 0, 0.1) + res_fail = SingleResult("alpine:3.20", DistroStatus.FAIL, 127, 0.2) + res_timeout = SingleResult("ubuntu:22.04", DistroStatus.TIMED_OUT, None, 1.0) + + report = RunReport([res_pass, res_fail, res_timeout]) + assert report.total_count == 3 + assert report.passed_count == 1 + assert report.failed_count == 2 + assert report.all_passed is False + + multi = MultiScriptReport({ + "s1.sh": RunReport([res_pass]), + "s2.sh": RunReport([res_fail]), + }) + assert multi.total_scripts_count == 2 + assert multi.passed_scripts_count == 1 + assert multi.failed_scripts_count == 1 + assert multi.all_passed is False + + +def test_reporter_rendering_suggested_package_and_new_diagnostics(): + from opsscript_gate.models import SingleResult, DistroStatus, FailureDiagnostic, RunReport + from opsscript_gate.reporter import format_markdown_compatibility_card, format_terminal_table, format_sarif + import json + + diag = FailureDiagnostic( + kind="permission_denied", + command="./install.sh", + message="permission denied: ./install.sh", + line=4, + hint="Target script lacks executable permissions.", + suggested_package=None, + ) + result = SingleResult("alpine:3.20", DistroStatus.FAIL, 126, 0.5, diagnostic=diag) + report = RunReport([result]) + + card = format_markdown_compatibility_card(report, "deploy.sh") + assert "Permission denied: `./install.sh` (line 4)" in card + + # 包含 suggested_package + diag_pkg = FailureDiagnostic( + kind="missing_command", + command="jq", + message="command not found: jq", + suggested_package="jq", + hint="Consider installing jq.", + ) + result_pkg = SingleResult("debian:12-slim", DistroStatus.FAIL, 127, 0.2, diagnostic=diag_pkg) + report_pkg = RunReport([result_pkg]) + + card_pkg = format_markdown_compatibility_card(report_pkg, "script.sh") + assert "Package: `jq`" in card_pkg + assert "**Suggested Package:** `jq`" in card_pkg + + # Terminal 报告验证 + terminal = format_terminal_table(report_pkg) + assert "(suggested package: jq)" in terminal + + # SARIF diagnosticKind 验证 + sarif = json.loads(format_sarif(report, "deploy.sh")) + props = sarif["runs"][0]["results"][0]["properties"] + assert props["diagnosticKind"] == "permission_denied" +