diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba6ba5..703cf13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [0.9.6] - 2026-11-08 + +### Added +- Added safe container environment variable injection via CLI `--env` and configuration `"env": {...}`, with strict POSIX variable name validation (`^[A-Za-z_][A-Za-z0-9_]*$`). +- Added host workspace context read-only mounting via CLI `--workdir` and configuration `"workdir": "..."`, automatically setting `/workspace` as the container working directory while strictly preserving host immutability (`mode: "ro"`). +- Added `env` and `workdir` inputs to the GitHub Composite Action with transparent argument forwarding. +- Expanded `COMMON_COMMAND_PACKAGES` to provide cross-distro package remediation for standard operations tools (`find`, `xargs`, `grep`, `sed`, `awk`, `diff`, `patch`, `ssh`, `rsync`, `htop`). +- Added Windows case-insensitive path collision protection in `_output_overwrites_protected` to prevent accidental overwrites of critical project files (e.g. `README.md`). + ## [0.9.5] - 2026-11-08 ### Added diff --git a/README.md b/README.md index 4fc00cd..43412fb 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ apt-get --version Run it locally with `opsscript-gate run ./install.sh`, or add the following step to CI: ```yaml -- uses: Mresyzz/opsscript-gate@v0.9.4 +- uses: Mresyzz/opsscript-gate@v0.9.6 with: script-path: install.sh ``` @@ -57,12 +57,9 @@ The result identifies the failing distribution, exit code, line, missing command --- -## Current release: v0.9.4 +## Current release: v0.9.6 -The current release includes a project config file, a dry-run plan, presets, exclusions, -saved reports, changed-script selection for pull requests, and repeat runs for detecting -flaky runtime behavior. The project controls -were introduced in v0.5.0; projects on v0.4.1 and earlier do not include them. +The current release includes safe environment variable passing (`--env`), host workspace context read-only mounting (`--workdir`), expanded package recommendations for standard Linux tools, structured diagnostic properties in SARIF, and composite action outputs. ```bash opsscript-gate init @@ -71,6 +68,8 @@ opsscript-gate run --dry-run opsscript-gate run --format json --output reports/compatibility.json # Repeat each distro three times when diagnosing intermittent failures opsscript-gate run ./install.sh --repeat 3 +# Pass environment variables and mount repository workspace read-only +opsscript-gate run ./install.sh --env APP_ENV=staging --workdir . ``` `init` creates `.opsscript-gate.json` and a GitHub Actions workflow without replacing @@ -115,7 +114,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: Mresyzz/opsscript-gate@v0.9.4 + - uses: Mresyzz/opsscript-gate@v0.9.6 ``` This zero-configuration form discovers shell scripts in the repository. Use @@ -126,7 +125,7 @@ repository has a single installer. For self-hosted model installers, see the Or test a specific script with custom execution modes: ```yaml - - uses: Mresyzz/opsscript-gate@v0.9.4 + - uses: Mresyzz/opsscript-gate@v0.9.6 with: script-path: scripts/install.sh shell: auto @@ -160,7 +159,7 @@ unrelated scripts: with: persist-credentials: false fetch-depth: 0 - - uses: Mresyzz/opsscript-gate@v0.9.4 + - uses: Mresyzz/opsscript-gate@v0.9.6 with: changed-since: ${{ github.event.pull_request.base.sha }} preset: minimal @@ -251,7 +250,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another SARIF-compatible viewer. Upload it explicitly with the official upload action: ```yaml -- uses: Mresyzz/opsscript-gate@v0.9.4 +- uses: Mresyzz/opsscript-gate@v0.9.6 with: script-path: scripts/install.sh format: sarif diff --git a/README.zh-CN.md b/README.zh-CN.md index 799c015..0dcaa28 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,9 +17,9 @@ Bash 依赖、包管理器假设和交互式阻塞。它与 ShellCheck 互补。 也可以直接打开 [OpsScript Gate 在线演示](https://mresyzz.github.io/opsscript-gate/),先查看 ShellCheck 与运行时验证的差异,再复制 workflow 到自己的仓库。 -## 当前版本 v0.9.4 +## 当前版本 v0.9.6 -v0.9.4 是当前已发布版本;如果你要从源码验证当前分支,也可以执行: +v0.9.6 是当前已发布版本;如果你要从源码验证当前分支,也可以执行: ```bash pip install -e . @@ -33,9 +33,11 @@ opsscript-gate init opsscript-gate doctor opsscript-gate run --dry-run opsscript-gate run --format json --output reports/compatibility.json +# 传递环境变量并以只读方式挂载本地工作空间 +opsscript-gate run ./install.sh --env APP_ENV=staging --workdir . ``` -`init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.4`。 +`init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.6`。 预览不需要 Docker;真实运行需要 Python 3.10+ 和可访问的 Linux Docker 引擎。 `opsscript-gate doctor` 可以在真实运行前检查 Python 和 Docker。 @@ -74,7 +76,7 @@ Docker daemon 和可选的项目配置。 - uses: actions/checkout@v7 with: fetch-depth: 0 -- uses: Mresyzz/opsscript-gate@v0.9.4 +- uses: Mresyzz/opsscript-gate@v0.9.6 with: changed-since: ${{ github.event.pull_request.base.sha }} preset: minimal diff --git a/action.yml b/action.yml index b3f890f..0c39f49 100644 --- a/action.yml +++ b/action.yml @@ -50,6 +50,14 @@ inputs: description: 'Optional comma- or newline-separated packages to install before running (requires network=bridge)' required: false default: '' + env: + description: 'Newline-separated list of environment variables to pass into containers (e.g. KEY=VALUE or KEY)' + required: false + default: '' + workdir: + description: 'Host directory to mount read-only into /workspace as the container working directory' + required: false + default: '' config: description: 'Path to project JSON configuration' @@ -116,6 +124,8 @@ runs: INPUT_PIDS_LIMIT: ${{ inputs.pids-limit }} INPUT_NETWORK: ${{ inputs.network }} INPUT_PACKAGES: ${{ inputs.packages }} + INPUT_ENV: ${{ inputs.env }} + INPUT_WORKDIR: ${{ inputs.workdir }} INPUT_CONFIG: ${{ inputs.config }} INPUT_PRESET: ${{ inputs.preset }} INPUT_EXCLUDE: ${{ inputs.exclude }} @@ -137,6 +147,7 @@ runs: [ -z "$INPUT_PIDS_LIMIT" ] || ARGS+=(--pids-limit "$INPUT_PIDS_LIMIT") [ -z "$INPUT_NETWORK" ] || ARGS+=(--network "$INPUT_NETWORK") [ -z "$INPUT_PACKAGES" ] || ARGS+=(--packages "$INPUT_PACKAGES") + [ -z "$INPUT_WORKDIR" ] || ARGS+=(--workdir "$INPUT_WORKDIR") [ -z "$INPUT_MATRIX" ] || ARGS+=(--matrix "$INPUT_MATRIX") [ -z "$INPUT_JOBS" ] || ARGS+=(--jobs "$INPUT_JOBS") [ -z "$INPUT_CONFIG" ] || ARGS+=(--config "$INPUT_CONFIG") @@ -144,6 +155,10 @@ runs: [ -z "$INPUT_MAX_SCRIPTS" ] || ARGS+=(--max-scripts "$INPUT_MAX_SCRIPTS") [ -z "$INPUT_OUTPUT" ] || ARGS+=(--output "$INPUT_OUTPUT") [ -z "$INPUT_CHANGED_SINCE" ] || ARGS+=(--changed-since "$INPUT_CHANGED_SINCE") + while IFS= read -r env_var; do + env_var="${env_var%$'\r'}" + [ -z "$env_var" ] || ARGS+=("--env" "$env_var") + done <<< "$INPUT_ENV" while IFS= read -r pattern; do pattern="${pattern%$'\r'}" [ -z "$pattern" ] || ARGS+=("--exclude" "$pattern") diff --git a/pyproject.toml b/pyproject.toml index c410891..944fe58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "opsscript-gate" -version = "0.9.5" +version = "0.9.6" 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 8564f66..3dde8bb 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.5" +__version__ = "0.9.6" __all__ = ["__version__"] diff --git a/src/opsscript_gate/cli.py b/src/opsscript_gate/cli.py index a70c4c4..9cf913e 100644 --- a/src/opsscript_gate/cli.py +++ b/src/opsscript_gate/cli.py @@ -9,7 +9,20 @@ from opsscript_gate import __version__ from opsscript_gate.discovery import discover_changed_scripts, discover_scripts -from opsscript_gate.config import DEFAULTS, MAX_MATRIX_IMAGES, MAX_REPEAT_ATTEMPTS, MAX_TIMEOUT_SECONDS, PRESETS, init_project, load_config, validate_memory_limit, validate_packages +from opsscript_gate.config import ( + DEFAULTS, + ENV_KEY_RE, + MAX_MATRIX_IMAGES, + MAX_REPEAT_ATTEMPTS, + MAX_TIMEOUT_SECONDS, + PRESETS, + init_project, + load_config, + validate_env, + validate_memory_limit, + validate_packages, + validate_workdir, +) from opsscript_gate.models import MultiScriptReport, RunReport from opsscript_gate.reporter import ( emit_github_annotations, @@ -144,6 +157,18 @@ def build_parser() -> argparse.ArgumentParser: default=None, help="Optional comma-separated packages to install in each container before running (repeatable; requires network=bridge)", ) + run_parser.add_argument( + "--env", + action="append", + default=None, + help="Environment variable to pass into containers in KEY=VALUE or KEY format (repeatable)", + ) + run_parser.add_argument( + "--workdir", + type=str, + default=None, + help="Host directory to mount read-only into /workspace as the container working directory", + ) run_parser.add_argument( "--changed-since", help="Only test shell scripts changed between this Git revision and HEAD", @@ -181,6 +206,33 @@ def parse_packages_argument(packages_raw: list[str] | None) -> list[str]: return validate_packages(packages) +def parse_env_arguments( + env_args: list[str] | None, + base_env: dict[str, str] | None = None, +) -> dict[str, str]: + """Parse repeated --env arguments, resolving KEY=VALUE or KEY from host env.""" + merged: dict[str, str] = dict(base_env or {}) + if not env_args: + return validate_env(merged) if merged else {} + for item in env_args: + for entry in item.splitlines(): + entry = entry.strip() + if not entry: + continue + if "=" in entry: + key, val = entry.split("=", 1) + else: + key = entry + val = os.environ.get(key, "") + key = key.strip() + if not ENV_KEY_RE.fullmatch(key): + raise ValueError( + f"Invalid environment variable name: '{key}'. Must be a valid POSIX identifier (^[A-Za-z_][A-Za-z0-9_]*$)" + ) + merged[key] = val + return validate_env(merged) + + def run_checked_matrix( args: argparse.Namespace, script_path: str, matrix: list[str] ) -> RunReport | None: @@ -197,6 +249,8 @@ def run_checked_matrix( network=args.network, packages=args.packages, repeat=args.repeat, + env=args.env, + workdir=args.workdir, ) except DockerDaemonError as err: sys.stderr.write(f"Docker Error: {err}\n") @@ -207,8 +261,11 @@ def run_checked_matrix( def _output_overwrites_protected(output_path: Path, protected: list[Path]) -> bool: """Reject paths that resolve to or share an inode with a protected file.""" - if output_path in protected: - return True + for p in protected: + if output_path == p: + return True + if os.name == "nt" and str(output_path).lower() == str(p).lower(): + return True if not output_path.exists(): return False for target in protected: @@ -316,6 +373,10 @@ def main(argv: list[str] | None = None) -> int: args.packages = parse_packages_argument(args.packages) if args.packages and args.network == "none": raise ValueError("packages require network=bridge") + base_env = config.get("env") if isinstance(config.get("env"), dict) else {} + args.env = parse_env_arguments(args.env, base_env=base_env) + if args.workdir: + args.workdir = validate_workdir(args.workdir) if args.matrix is not None and not any(x.strip() for x in args.matrix.split(",")): raise ValueError("matrix must contain at least one image") except ValueError as err: @@ -385,14 +446,18 @@ def main(argv: list[str] | None = None) -> int: plan = {"dry_run": True, "scripts": target_scripts, "matrix": matrix, "executions": len(target_scripts) * len(matrix) * args.repeat, "shell": args.shell, "network": args.network, "packages": args.packages, "timeout": args.timeout, - "repeat": args.repeat} + "repeat": args.repeat, "env": args.env, "workdir": args.workdir} if changed_selection: plan["changed_since"] = args.changed_since + env_preview = f"\nEnv: {', '.join(f'{k}={v}' for k, v in args.env.items())}" if args.env else "" + workdir_preview = f"\nWorkdir: {args.workdir}" if args.workdir else "" output = json.dumps(plan, indent=2) if args.format == "json" else ( "Execution preview (no scripts executed)\n" + "\n".join(f" {path}" for path in target_scripts) + f"\nImages: {', '.join(matrix)}\nContainer executions: {plan['executions']}" + (f"\nPackages: {', '.join(args.packages)}" if args.packages else "") + + env_preview + + workdir_preview + f"\nShell: {args.shell} | Network: {args.network} | Timeout: {args.timeout}s | Repeat: {args.repeat}" ) return 0 if publish_output(output, args.output) else 1 diff --git a/src/opsscript_gate/config.py b/src/opsscript_gate/config.py index e681cd4..9fd7a73 100644 --- a/src/opsscript_gate/config.py +++ b/src/opsscript_gate/config.py @@ -17,7 +17,7 @@ "matrix": None, "preset": None, "jobs": None, "timeout": 60, "shell": "posix", "mem_limit": "256m", "pids_limit": 128, "network": "bridge", "packages": [], "exclude": [], "max_scripts": 20, - "repeat": 1, + "repeat": 1, "env": {}, "workdir": None, } MAX_CONFIG_SIZE_BYTES = 256 * 1024 @@ -26,6 +26,39 @@ MAX_MATRIX_IMAGES = 32 PACKAGE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+._:-]*$") +ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def validate_env(value: object) -> dict[str, str]: + """Validate and normalize environment variables specified in configuration or CLI.""" + if not isinstance(value, dict): + raise ValueError("env must be a JSON object mapping variable names to string values") + normalized: dict[str, str] = {} + for k, v in value.items(): + if not isinstance(k, str) or not ENV_KEY_RE.fullmatch(k): + raise ValueError( + f"Invalid environment variable name '{k}'. Must be a valid POSIX identifier (^[A-Za-z_][A-Za-z0-9_]*$)" + ) + if not isinstance(v, str): + raise ValueError( + f"Environment variable '{k}' value must be a string, got {type(v).__name__}" + ) + normalized[k] = v + return normalized + + +def validate_workdir(value: object) -> str: + """Validate that the workspace directory exists, is a directory, and is not root.""" + if not isinstance(value, str) or not value.strip(): + raise ValueError("workdir must be a nonempty path string") + path = Path(value.strip()).resolve() + if not path.exists(): + raise ValueError(f"workdir directory does not exist: {value}") + if not path.is_dir(): + raise ValueError(f"workdir is not a directory: {value}") + if path == path.parent: + raise ValueError(f"workdir must not be the root directory: {value}") + return str(path) def validate_memory_limit(value: object) -> str: @@ -93,6 +126,10 @@ def load_config(filename: str | None) -> dict: validate_packages(value) elif key == "mem_limit": validate_memory_limit(value) + elif key == "env": + validate_env(value) + elif key == "workdir": + validate_workdir(value) elif not isinstance(value, str) or not value.strip(): raise ValueError(f"{key} must be a nonempty string") for key, choices in {"shell": ("posix", "auto", "shebang"), @@ -126,7 +163,7 @@ def init_project() -> list[str]: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: Mresyzz/opsscript-gate@v0.9.5 + - uses: Mresyzz/opsscript-gate@v0.9.6 with: config: .opsscript-gate.json """, diff --git a/src/opsscript_gate/remediation.py b/src/opsscript_gate/remediation.py index 0c801c6..3fb1557 100644 --- a/src/opsscript_gate/remediation.py +++ b/src/opsscript_gate/remediation.py @@ -123,6 +123,16 @@ def _match_interpreter(distro: str | None, command: str | None, kind: str) -> bo "envsubst": {"debian": "gettext-base", "alpine": "gettext", "rhel": "gettext"}, "crontab": {"debian": "cron", "alpine": "dcron", "rhel": "cronie"}, "ps": {"debian": "procps", "alpine": "procps", "rhel": "procps-ng"}, + "find": {"debian": "findutils", "alpine": "findutils", "rhel": "findutils"}, + "xargs": {"debian": "findutils", "alpine": "findutils", "rhel": "findutils"}, + "grep": {"debian": "grep", "alpine": "grep", "rhel": "grep"}, + "sed": {"debian": "sed", "alpine": "sed", "rhel": "sed"}, + "awk": {"debian": "gawk", "alpine": "gawk", "rhel": "gawk"}, + "diff": {"debian": "diffutils", "alpine": "diffutils", "rhel": "diffutils"}, + "patch": {"debian": "patch", "alpine": "patch", "rhel": "patch"}, + "ssh": {"debian": "openssh-client", "alpine": "openssh-client", "rhel": "openssh-clients"}, + "rsync": {"debian": "rsync", "alpine": "rsync", "rhel": "rsync"}, + "htop": {"debian": "htop", "alpine": "htop", "rhel": "htop"}, } diff --git a/src/opsscript_gate/runner.py b/src/opsscript_gate/runner.py index 9e034bb..b71eac3 100644 --- a/src/opsscript_gate/runner.py +++ b/src/opsscript_gate/runner.py @@ -16,7 +16,17 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from enum import Enum -from opsscript_gate.config import MAX_MATRIX_IMAGES, MAX_REPEAT_ATTEMPTS, MAX_TIMEOUT_SECONDS, PACKAGE_NAME_RE, validate_memory_limit, validate_packages +from opsscript_gate.config import ( + ENV_KEY_RE, + MAX_MATRIX_IMAGES, + MAX_REPEAT_ATTEMPTS, + MAX_TIMEOUT_SECONDS, + PACKAGE_NAME_RE, + validate_env, + validate_memory_limit, + validate_packages, + validate_workdir, +) from opsscript_gate.models import DistroStatus, FailureDiagnostic, RunReport, ShellMode, SingleResult from opsscript_gate.remediation import generate_remediation_hint, resolve_package_recommendation @@ -804,6 +814,8 @@ def _runtime_option_error( pids_limit: int, network: str, packages: Sequence[str], + env: dict[str, str] | None = None, + workdir: str | None = None, ) -> str | None: """Validate execution options shared by the public runner entry points.""" if isinstance(packages, (str, bytes)): @@ -823,6 +835,10 @@ def _runtime_option_error( try: validate_memory_limit(mem_limit) validate_packages(list(packages)) + if env is not None: + validate_env(env) + if workdir is not None: + validate_workdir(workdir) except (TypeError, ValueError) as exc: return str(exc) return None @@ -859,6 +875,8 @@ def run_on_distro( network: str = "bridge", packages: Sequence[str] | None = None, prepared_script_path: str | None = None, + env: dict[str, str] | None = None, + workdir: str | None = None, ) -> SingleResult: """ Run a target script inside an unprivileged, non-interactive container. @@ -874,7 +892,7 @@ def run_on_distro( error_message="packages must be a list of package names", ) package_list = list(packages or []) - option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list) + option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list, env=env, workdir=workdir) if option_error: return SingleResult( distro=distro, @@ -988,10 +1006,20 @@ def run_on_distro( "mode": "ro", } } + if workdir is not None: + safe_workdir = normalize_host_path_for_docker(os.path.abspath(workdir)) + volumes[safe_workdir] = { + "bind": "/workspace", + "mode": "ro", + } + environment = { "DEBIAN_FRONTEND": "noninteractive", "CI": "true", } + if env: + environment.update(env) + create_kwargs = { "image": distro, "command": container_command, @@ -1014,6 +1042,8 @@ def run_on_distro( ), "detach": True, } + if workdir is not None: + create_kwargs["working_dir"] = "/workspace" try: container = client.containers.create(**create_kwargs) except ImageNotFound: @@ -1188,6 +1218,8 @@ def run_matrix( network: str = "bridge", packages: Sequence[str] | None = None, repeat: int = 1, + env: dict[str, str] | None = None, + workdir: str | None = None, ) -> RunReport: """ Run the compatibility check across all specified Linux distributions, @@ -1247,7 +1279,7 @@ def run_matrix( if isinstance(packages, (str, bytes)): raise ValueError("packages must be a list of package names") package_list = list(packages or []) - option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list) + option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list, env=env, workdir=workdir) if option_error: raise ValueError(option_error) package_list = validate_packages(package_list) @@ -1299,6 +1331,8 @@ def _worker(index: int, distro_name: str) -> tuple[int, SingleResult]: network=network, packages=package_list, prepared_script_path=prepared_script, + env=env, + workdir=workdir, )) if repeat == 1: diff --git a/tests/test_action_contract.py b/tests/test_action_contract.py index 296297e..d1de2d7 100644 --- a/tests/test_action_contract.py +++ b/tests/test_action_contract.py @@ -9,12 +9,12 @@ def test_action_metadata_is_valid_and_inputs_are_wired(): text = (ROOT / "action.yml").read_text(encoding="utf-8") assert "using: 'composite'" in text - for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since", "packages", "repeat"): + for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since", "packages", "repeat", "env", "workdir"): assert f" {name}:" in text run_text = text for flag in ("--timeout", "--format", "--shell", "--mem-limit", "--pids-limit", "--network", "--packages", "--matrix", "--jobs", "--config", "--preset", "--repeat", - "--max-scripts", "--output", "--exclude"): + "--max-scripts", "--output", "--exclude", "--env", "--workdir"): assert flag in run_text assert "json, or sarif" in text assert "--changed-since" in run_text diff --git a/tests/test_audit_coverage.py b/tests/test_audit_coverage.py index c50f96a..1c4afc6 100644 --- a/tests/test_audit_coverage.py +++ b/tests/test_audit_coverage.py @@ -721,3 +721,206 @@ def test_reporter_rendering_suggested_package_and_new_diagnostics(): props = sarif["runs"][0]["results"][0]["properties"] assert props["diagnosticKind"] == "permission_denied" + +# ============================================================================== +# 11. 环境变量安全注入与工作空间目录挂载专项测试 (v0.9.6) +# ============================================================================== + +def test_validate_env_valid_and_invalid(): + from opsscript_gate.config import validate_env + + # 合法情况 + assert validate_env({"FOO": "bar", "_BAZ_123": "val"}) == {"FOO": "bar", "_BAZ_123": "val"} + assert validate_env({}) == {} + + # 非字典拒绝 + with pytest.raises(ValueError, match="env must be a JSON object"): + validate_env(["FOO=bar"]) + + # 非法 key 命名拒绝 + with pytest.raises(ValueError, match="Invalid environment variable name '123FOO'"): + validate_env({"123FOO": "bar"}) + + with pytest.raises(ValueError, match="Invalid environment variable name 'FOO-BAR'"): + validate_env({"FOO-BAR": "bar"}) + + # 非字符串 value 拒绝 + with pytest.raises(ValueError, match="Environment variable 'FOO' value must be a string"): + validate_env({"FOO": 123}) + + +def test_validate_workdir_valid_and_invalid(tmp_path): + from opsscript_gate.config import validate_workdir + + # 合法目录 + sub_dir = tmp_path / "my_workspace" + sub_dir.mkdir() + assert validate_workdir(str(sub_dir)) == str(sub_dir.resolve()) + + # 不存在目录拒绝 + with pytest.raises(ValueError, match="workdir directory does not exist"): + validate_workdir(str(tmp_path / "non_existent")) + + # 指向普通文件拒绝 + file_path = tmp_path / "regular.sh" + file_path.write_text("#!/bin/sh\n") + with pytest.raises(ValueError, match="workdir is not a directory"): + validate_workdir(str(file_path)) + + # 空或非字符串拒绝 + with pytest.raises(ValueError, match="workdir must be a nonempty path string"): + validate_workdir("") + with pytest.raises(ValueError, match="workdir must be a nonempty path string"): + validate_workdir(123) + + +def test_config_env_and_workdir_support(tmp_path): + from opsscript_gate.config import load_config + import json + + ws = tmp_path / "ws" + ws.mkdir() + cfg_file = tmp_path / ".opsscript-gate.json" + cfg_file.write_text(json.dumps({ + "env": {"APP_PORT": "8080", "APP_ENV": "staging"}, + "workdir": str(ws), + })) + + loaded = load_config(str(cfg_file)) + assert loaded["env"] == {"APP_PORT": "8080", "APP_ENV": "staging"} + assert loaded["workdir"] == str(ws.resolve()) + + +def test_parse_env_arguments_cli(monkeypatch): + from opsscript_gate.cli import parse_env_arguments + + monkeypatch.setenv("EXISTING_VAR", "host_value") + + # KEY=VALUE 格式 + res = parse_env_arguments(["FOO=123", "BAR=abc=def"]) + assert res == {"FOO": "123", "BAR": "abc=def"} + + # 单独 KEY(从宿主机环境变量获取) + res_host = parse_env_arguments(["EXISTING_VAR", "NON_EXISTING"]) + assert res_host["EXISTING_VAR"] == "host_value" + assert res_host["NON_EXISTING"] == "" + + # 多行输入 + res_multiline = parse_env_arguments(["A=1\nB=2\n\nC=3"]) + assert res_multiline == {"A": "1", "B": "2", "C": "3"} + + # 优先级合并:命令行覆盖 base_env + res_merge = parse_env_arguments(["A=cli_val", "B=2"], base_env={"A": "base_val", "C": "3"}) + assert res_merge == {"A": "cli_val", "B": "2", "C": "3"} + + # 非法名称抛错 + with pytest.raises(ValueError, match="Invalid environment variable name: 'BAD-NAME'"): + parse_env_arguments(["BAD-NAME=1"]) + + +def test_cli_output_overwrites_protected_case_insensitive(tmp_path): + from opsscript_gate.cli import _output_overwrites_protected + from pathlib import Path + + file_a = tmp_path / "README.md" + file_a.write_text("hello") + + # 同一路径或大小写变体 + assert _output_overwrites_protected(file_a, [file_a]) is True + file_lower = tmp_path / "readme.md" + assert _output_overwrites_protected(file_lower, [file_a]) is True + + # 未受保护的文件 + file_other = tmp_path / "other.txt" + assert _output_overwrites_protected(file_other, [file_a]) is False + + +def test_run_matrix_with_env_and_workdir_mock(tmp_path): + from opsscript_gate.runner import run_matrix + from unittest.mock import MagicMock + + script = tmp_path / "test.sh" + script.write_text("#!/bin/sh\necho ok\n") + + ws = tmp_path / "my_workdir" + ws.mkdir() + + mock_client = MagicMock() + mock_container = MagicMock() + mock_container.wait.return_value = {"StatusCode": 0} + mock_container.logs.return_value = b"ok\n" + mock_container.attrs = {"State": {"ExitCode": 0}} + mock_client.containers.create.return_value = mock_container + + report = run_matrix( + script_path=str(script), + matrix=["debian:12-slim"], + client=mock_client, + env={"TEST_KEY": "test_val"}, + workdir=str(ws), + ) + + assert report.all_passed is True + create_call = mock_client.containers.create.call_args[1] + + # 验证环境变量合并 + assert create_call["environment"]["TEST_KEY"] == "test_val" + assert create_call["environment"]["CI"] == "true" + assert create_call["environment"]["DEBIAN_FRONTEND"] == "noninteractive" + + # 验证工作区只读挂载与工作目录配置 + assert create_call["working_dir"] == "/workspace" + found_ws_mount = False + for host_vol, cfg in create_call["volumes"].items(): + if cfg["bind"] == "/workspace": + assert cfg["mode"] == "ro" + found_ws_mount = True + assert found_ws_mount is True + + +def test_cli_dry_run_displays_env_and_workdir(tmp_path, capsys): + from opsscript_gate.cli import main + import json + + script = tmp_path / "script.sh" + script.write_text("#!/bin/sh\n") + + ws = tmp_path / "workspace" + ws.mkdir() + + code = main([ + "run", + str(script), + "--dry-run", + "--format", "json", + "--env", "VAR_A=1", + "--workdir", str(ws), + ]) + assert code == 0 + captured = capsys.readouterr().out + plan = json.loads(captured) + assert plan["env"] == {"VAR_A": "1"} + assert plan["workdir"] == str(ws.resolve()) + + +def test_expanded_remediation_packages(): + from opsscript_gate.remediation import resolve_package_recommendation + + for cmd, deb_expected, alp_expected in [ + ("find", "findutils", "findutils"), + ("xargs", "findutils", "findutils"), + ("grep", "grep", "grep"), + ("sed", "sed", "sed"), + ("awk", "gawk", "gawk"), + ("diff", "diffutils", "diffutils"), + ("patch", "patch", "patch"), + ("ssh", "openssh-client", "openssh-client"), + ("rsync", "rsync", "rsync"), + ("htop", "htop", "htop"), + ]: + pkg_deb, _ = resolve_package_recommendation(cmd, "debian:12-slim") + assert pkg_deb == deb_expected, f"Failed for debian {cmd}" + pkg_alp, _ = resolve_package_recommendation(cmd, "alpine:3.20") + assert pkg_alp == alp_expected, f"Failed for alpine {cmd}" + + diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 2e1e09d..c8313dc 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -81,7 +81,9 @@ def test_init_existing_workflow_leaves_config_absent(project): {"jobs": 0}, {"exclude": "tests/*"}, {"shell": "fish"}, {"network": "host"}, {"preset": "unknown"}, {"matrix": "alpine", "preset": "minimal"}, {"matrix": ","}, {"mem_limit": "0"}, {"mem_limit": "unlimited"}, {"repeat": 21}, {"timeout": 3601}, - {"packages": "curl"}, {"packages": ["curl; touch pwned"]}]) + {"packages": "curl"}, {"packages": ["curl; touch pwned"]}, + {"env": "not_a_dict"}, {"env": {"1_BAD": "val"}}, {"env": {"OK": 123}}, + {"workdir": 123}, {"workdir": ""}]) def test_invalid_config(project, config): Path(CONFIG_NAME).write_text(json.dumps(config)) with patch("opsscript_gate.cli.run_matrix") as run: