opsscript-gate 0.9.5__tar.gz → 0.9.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/CHANGELOG.md +9 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/PKG-INFO +10 -11
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/README.md +9 -10
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/README.zh-CN.md +6 -4
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/action.yml +15 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/pyproject.toml +1 -1
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/__init__.py +1 -1
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/cli.py +69 -4
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/config.py +39 -2
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/remediation.py +10 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/runner.py +37 -3
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/test_action_contract.py +2 -2
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/test_audit_coverage.py +206 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/test_onboarding.py +3 -1
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.gitattributes +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/assets/social-preview.png +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/pull_request_template.md +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/workflow-templates/opsscript-gate.properties.json +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/workflow-templates/opsscript-gate.yml +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/workflows/demo.yml +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/workflows/deploy-pages.yml +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/workflows/release.yml +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/workflows/test.yml +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.gitignore +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/CITATION.cff +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/CONTRIBUTING.md +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/LICENSE +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/ROADMAP.md +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/SECURITY.md +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/demo.html +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/command-not-found.html +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/command-not-found.md +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/configuration.md +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/gpt-oss.md +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/index.html +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/installer-smoke-test.html +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/installer-smoke-test.md +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/llms.txt +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/robots.txt +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/sitemap.xml +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/troubleshooting.md +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/alpine-incompatibility/README.md +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/basic/README.md +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/basic/clean_setup.sh +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/github-actions/README.md +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/github-actions/installer-smoke.yml +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/github-actions/workflow.yml +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/interactive-hang/README.md +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/interactive-hang/prompt_confirm.sh +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/index.html +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/llms.txt +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/discovery.py +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/models.py +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/reporter.py +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/conftest.py +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/fixtures/fail_deps.sh +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/fixtures/fail_hang.sh +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/fixtures/pass_basic.sh +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/test_installer_example.py +0 -0
- {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/test_runner.py +0 -0
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.6] - 2026-11-08
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- 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_]*$`).
|
|
7
|
+
- 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"`).
|
|
8
|
+
- Added `env` and `workdir` inputs to the GitHub Composite Action with transparent argument forwarding.
|
|
9
|
+
- Expanded `COMMON_COMMAND_PACKAGES` to provide cross-distro package remediation for standard operations tools (`find`, `xargs`, `grep`, `sed`, `awk`, `diff`, `patch`, `ssh`, `rsync`, `htop`).
|
|
10
|
+
- Added Windows case-insensitive path collision protection in `_output_overwrites_protected` to prevent accidental overwrites of critical project files (e.g. `README.md`).
|
|
11
|
+
|
|
3
12
|
## [0.9.5] - 2026-11-08
|
|
4
13
|
|
|
5
14
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: opsscript-gate
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.6
|
|
4
4
|
Summary: Cross-distro shell script compatibility and portability checker for Debian, Ubuntu, and Alpine. Runs real containers to diagnose command not found.
|
|
5
5
|
Project-URL: Homepage, https://mresyzz.github.io/opsscript-gate/
|
|
6
6
|
Project-URL: Demo, https://mresyzz.github.io/opsscript-gate/
|
|
@@ -81,7 +81,7 @@ apt-get --version
|
|
|
81
81
|
Run it locally with `opsscript-gate run ./install.sh`, or add the following step to CI:
|
|
82
82
|
|
|
83
83
|
```yaml
|
|
84
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
84
|
+
- uses: Mresyzz/opsscript-gate@v0.9.6
|
|
85
85
|
with:
|
|
86
86
|
script-path: install.sh
|
|
87
87
|
```
|
|
@@ -90,12 +90,9 @@ The result identifies the failing distribution, exit code, line, missing command
|
|
|
90
90
|
|
|
91
91
|
---
|
|
92
92
|
|
|
93
|
-
## Current release: v0.9.
|
|
93
|
+
## Current release: v0.9.6
|
|
94
94
|
|
|
95
|
-
The current release includes
|
|
96
|
-
saved reports, changed-script selection for pull requests, and repeat runs for detecting
|
|
97
|
-
flaky runtime behavior. The project controls
|
|
98
|
-
were introduced in v0.5.0; projects on v0.4.1 and earlier do not include them.
|
|
95
|
+
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.
|
|
99
96
|
|
|
100
97
|
```bash
|
|
101
98
|
opsscript-gate init
|
|
@@ -104,6 +101,8 @@ opsscript-gate run --dry-run
|
|
|
104
101
|
opsscript-gate run --format json --output reports/compatibility.json
|
|
105
102
|
# Repeat each distro three times when diagnosing intermittent failures
|
|
106
103
|
opsscript-gate run ./install.sh --repeat 3
|
|
104
|
+
# Pass environment variables and mount repository workspace read-only
|
|
105
|
+
opsscript-gate run ./install.sh --env APP_ENV=staging --workdir .
|
|
107
106
|
```
|
|
108
107
|
|
|
109
108
|
`init` creates `.opsscript-gate.json` and a GitHub Actions workflow without replacing
|
|
@@ -148,7 +147,7 @@ jobs:
|
|
|
148
147
|
- uses: actions/checkout@v7
|
|
149
148
|
with:
|
|
150
149
|
persist-credentials: false
|
|
151
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
150
|
+
- uses: Mresyzz/opsscript-gate@v0.9.6
|
|
152
151
|
```
|
|
153
152
|
|
|
154
153
|
This zero-configuration form discovers shell scripts in the repository. Use
|
|
@@ -159,7 +158,7 @@ repository has a single installer. For self-hosted model installers, see the
|
|
|
159
158
|
Or test a specific script with custom execution modes:
|
|
160
159
|
|
|
161
160
|
```yaml
|
|
162
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
161
|
+
- uses: Mresyzz/opsscript-gate@v0.9.6
|
|
163
162
|
with:
|
|
164
163
|
script-path: scripts/install.sh
|
|
165
164
|
shell: auto
|
|
@@ -193,7 +192,7 @@ unrelated scripts:
|
|
|
193
192
|
with:
|
|
194
193
|
persist-credentials: false
|
|
195
194
|
fetch-depth: 0
|
|
196
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
195
|
+
- uses: Mresyzz/opsscript-gate@v0.9.6
|
|
197
196
|
with:
|
|
198
197
|
changed-since: ${{ github.event.pull_request.base.sha }}
|
|
199
198
|
preset: minimal
|
|
@@ -284,7 +283,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another
|
|
|
284
283
|
SARIF-compatible viewer. Upload it explicitly with the official upload action:
|
|
285
284
|
|
|
286
285
|
```yaml
|
|
287
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
286
|
+
- uses: Mresyzz/opsscript-gate@v0.9.6
|
|
288
287
|
with:
|
|
289
288
|
script-path: scripts/install.sh
|
|
290
289
|
format: sarif
|
|
@@ -48,7 +48,7 @@ apt-get --version
|
|
|
48
48
|
Run it locally with `opsscript-gate run ./install.sh`, or add the following step to CI:
|
|
49
49
|
|
|
50
50
|
```yaml
|
|
51
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
51
|
+
- uses: Mresyzz/opsscript-gate@v0.9.6
|
|
52
52
|
with:
|
|
53
53
|
script-path: install.sh
|
|
54
54
|
```
|
|
@@ -57,12 +57,9 @@ The result identifies the failing distribution, exit code, line, missing command
|
|
|
57
57
|
|
|
58
58
|
---
|
|
59
59
|
|
|
60
|
-
## Current release: v0.9.
|
|
60
|
+
## Current release: v0.9.6
|
|
61
61
|
|
|
62
|
-
The current release includes
|
|
63
|
-
saved reports, changed-script selection for pull requests, and repeat runs for detecting
|
|
64
|
-
flaky runtime behavior. The project controls
|
|
65
|
-
were introduced in v0.5.0; projects on v0.4.1 and earlier do not include them.
|
|
62
|
+
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.
|
|
66
63
|
|
|
67
64
|
```bash
|
|
68
65
|
opsscript-gate init
|
|
@@ -71,6 +68,8 @@ opsscript-gate run --dry-run
|
|
|
71
68
|
opsscript-gate run --format json --output reports/compatibility.json
|
|
72
69
|
# Repeat each distro three times when diagnosing intermittent failures
|
|
73
70
|
opsscript-gate run ./install.sh --repeat 3
|
|
71
|
+
# Pass environment variables and mount repository workspace read-only
|
|
72
|
+
opsscript-gate run ./install.sh --env APP_ENV=staging --workdir .
|
|
74
73
|
```
|
|
75
74
|
|
|
76
75
|
`init` creates `.opsscript-gate.json` and a GitHub Actions workflow without replacing
|
|
@@ -115,7 +114,7 @@ jobs:
|
|
|
115
114
|
- uses: actions/checkout@v7
|
|
116
115
|
with:
|
|
117
116
|
persist-credentials: false
|
|
118
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
117
|
+
- uses: Mresyzz/opsscript-gate@v0.9.6
|
|
119
118
|
```
|
|
120
119
|
|
|
121
120
|
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
|
|
|
126
125
|
Or test a specific script with custom execution modes:
|
|
127
126
|
|
|
128
127
|
```yaml
|
|
129
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
128
|
+
- uses: Mresyzz/opsscript-gate@v0.9.6
|
|
130
129
|
with:
|
|
131
130
|
script-path: scripts/install.sh
|
|
132
131
|
shell: auto
|
|
@@ -160,7 +159,7 @@ unrelated scripts:
|
|
|
160
159
|
with:
|
|
161
160
|
persist-credentials: false
|
|
162
161
|
fetch-depth: 0
|
|
163
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
162
|
+
- uses: Mresyzz/opsscript-gate@v0.9.6
|
|
164
163
|
with:
|
|
165
164
|
changed-since: ${{ github.event.pull_request.base.sha }}
|
|
166
165
|
preset: minimal
|
|
@@ -251,7 +250,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another
|
|
|
251
250
|
SARIF-compatible viewer. Upload it explicitly with the official upload action:
|
|
252
251
|
|
|
253
252
|
```yaml
|
|
254
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
253
|
+
- uses: Mresyzz/opsscript-gate@v0.9.6
|
|
255
254
|
with:
|
|
256
255
|
script-path: scripts/install.sh
|
|
257
256
|
format: sarif
|
|
@@ -17,9 +17,9 @@ Bash 依赖、包管理器假设和交互式阻塞。它与 ShellCheck 互补。
|
|
|
17
17
|
|
|
18
18
|
也可以直接打开 [OpsScript Gate 在线演示](https://mresyzz.github.io/opsscript-gate/),先查看 ShellCheck 与运行时验证的差异,再复制 workflow 到自己的仓库。
|
|
19
19
|
|
|
20
|
-
## 当前版本 v0.9.
|
|
20
|
+
## 当前版本 v0.9.6
|
|
21
21
|
|
|
22
|
-
v0.9.
|
|
22
|
+
v0.9.6 是当前已发布版本;如果你要从源码验证当前分支,也可以执行:
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
pip install -e .
|
|
@@ -33,9 +33,11 @@ opsscript-gate init
|
|
|
33
33
|
opsscript-gate doctor
|
|
34
34
|
opsscript-gate run --dry-run
|
|
35
35
|
opsscript-gate run --format json --output reports/compatibility.json
|
|
36
|
+
# 传递环境变量并以只读方式挂载本地工作空间
|
|
37
|
+
opsscript-gate run ./install.sh --env APP_ENV=staging --workdir .
|
|
36
38
|
```
|
|
37
39
|
|
|
38
|
-
`init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.
|
|
40
|
+
`init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.6`。
|
|
39
41
|
预览不需要 Docker;真实运行需要 Python 3.10+
|
|
40
42
|
和可访问的 Linux Docker 引擎。
|
|
41
43
|
`opsscript-gate doctor` 可以在真实运行前检查 Python 和 Docker。
|
|
@@ -74,7 +76,7 @@ Docker daemon 和可选的项目配置。
|
|
|
74
76
|
- uses: actions/checkout@v7
|
|
75
77
|
with:
|
|
76
78
|
fetch-depth: 0
|
|
77
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
79
|
+
- uses: Mresyzz/opsscript-gate@v0.9.6
|
|
78
80
|
with:
|
|
79
81
|
changed-since: ${{ github.event.pull_request.base.sha }}
|
|
80
82
|
preset: minimal
|
|
@@ -50,6 +50,14 @@ inputs:
|
|
|
50
50
|
description: 'Optional comma- or newline-separated packages to install before running (requires network=bridge)'
|
|
51
51
|
required: false
|
|
52
52
|
default: ''
|
|
53
|
+
env:
|
|
54
|
+
description: 'Newline-separated list of environment variables to pass into containers (e.g. KEY=VALUE or KEY)'
|
|
55
|
+
required: false
|
|
56
|
+
default: ''
|
|
57
|
+
workdir:
|
|
58
|
+
description: 'Host directory to mount read-only into /workspace as the container working directory'
|
|
59
|
+
required: false
|
|
60
|
+
default: ''
|
|
53
61
|
|
|
54
62
|
config:
|
|
55
63
|
description: 'Path to project JSON configuration'
|
|
@@ -116,6 +124,8 @@ runs:
|
|
|
116
124
|
INPUT_PIDS_LIMIT: ${{ inputs.pids-limit }}
|
|
117
125
|
INPUT_NETWORK: ${{ inputs.network }}
|
|
118
126
|
INPUT_PACKAGES: ${{ inputs.packages }}
|
|
127
|
+
INPUT_ENV: ${{ inputs.env }}
|
|
128
|
+
INPUT_WORKDIR: ${{ inputs.workdir }}
|
|
119
129
|
INPUT_CONFIG: ${{ inputs.config }}
|
|
120
130
|
INPUT_PRESET: ${{ inputs.preset }}
|
|
121
131
|
INPUT_EXCLUDE: ${{ inputs.exclude }}
|
|
@@ -137,6 +147,7 @@ runs:
|
|
|
137
147
|
[ -z "$INPUT_PIDS_LIMIT" ] || ARGS+=(--pids-limit "$INPUT_PIDS_LIMIT")
|
|
138
148
|
[ -z "$INPUT_NETWORK" ] || ARGS+=(--network "$INPUT_NETWORK")
|
|
139
149
|
[ -z "$INPUT_PACKAGES" ] || ARGS+=(--packages "$INPUT_PACKAGES")
|
|
150
|
+
[ -z "$INPUT_WORKDIR" ] || ARGS+=(--workdir "$INPUT_WORKDIR")
|
|
140
151
|
[ -z "$INPUT_MATRIX" ] || ARGS+=(--matrix "$INPUT_MATRIX")
|
|
141
152
|
[ -z "$INPUT_JOBS" ] || ARGS+=(--jobs "$INPUT_JOBS")
|
|
142
153
|
[ -z "$INPUT_CONFIG" ] || ARGS+=(--config "$INPUT_CONFIG")
|
|
@@ -144,6 +155,10 @@ runs:
|
|
|
144
155
|
[ -z "$INPUT_MAX_SCRIPTS" ] || ARGS+=(--max-scripts "$INPUT_MAX_SCRIPTS")
|
|
145
156
|
[ -z "$INPUT_OUTPUT" ] || ARGS+=(--output "$INPUT_OUTPUT")
|
|
146
157
|
[ -z "$INPUT_CHANGED_SINCE" ] || ARGS+=(--changed-since "$INPUT_CHANGED_SINCE")
|
|
158
|
+
while IFS= read -r env_var; do
|
|
159
|
+
env_var="${env_var%$'\r'}"
|
|
160
|
+
[ -z "$env_var" ] || ARGS+=("--env" "$env_var")
|
|
161
|
+
done <<< "$INPUT_ENV"
|
|
147
162
|
while IFS= read -r pattern; do
|
|
148
163
|
pattern="${pattern%$'\r'}"
|
|
149
164
|
[ -z "$pattern" ] || ARGS+=("--exclude" "$pattern")
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opsscript-gate"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.6"
|
|
8
8
|
description = "Cross-distro shell script compatibility and portability checker for Debian, Ubuntu, and Alpine. Runs real containers to diagnose command not found."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -9,7 +9,20 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
from opsscript_gate import __version__
|
|
11
11
|
from opsscript_gate.discovery import discover_changed_scripts, discover_scripts
|
|
12
|
-
from opsscript_gate.config import
|
|
12
|
+
from opsscript_gate.config import (
|
|
13
|
+
DEFAULTS,
|
|
14
|
+
ENV_KEY_RE,
|
|
15
|
+
MAX_MATRIX_IMAGES,
|
|
16
|
+
MAX_REPEAT_ATTEMPTS,
|
|
17
|
+
MAX_TIMEOUT_SECONDS,
|
|
18
|
+
PRESETS,
|
|
19
|
+
init_project,
|
|
20
|
+
load_config,
|
|
21
|
+
validate_env,
|
|
22
|
+
validate_memory_limit,
|
|
23
|
+
validate_packages,
|
|
24
|
+
validate_workdir,
|
|
25
|
+
)
|
|
13
26
|
from opsscript_gate.models import MultiScriptReport, RunReport
|
|
14
27
|
from opsscript_gate.reporter import (
|
|
15
28
|
emit_github_annotations,
|
|
@@ -144,6 +157,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
144
157
|
default=None,
|
|
145
158
|
help="Optional comma-separated packages to install in each container before running (repeatable; requires network=bridge)",
|
|
146
159
|
)
|
|
160
|
+
run_parser.add_argument(
|
|
161
|
+
"--env",
|
|
162
|
+
action="append",
|
|
163
|
+
default=None,
|
|
164
|
+
help="Environment variable to pass into containers in KEY=VALUE or KEY format (repeatable)",
|
|
165
|
+
)
|
|
166
|
+
run_parser.add_argument(
|
|
167
|
+
"--workdir",
|
|
168
|
+
type=str,
|
|
169
|
+
default=None,
|
|
170
|
+
help="Host directory to mount read-only into /workspace as the container working directory",
|
|
171
|
+
)
|
|
147
172
|
run_parser.add_argument(
|
|
148
173
|
"--changed-since",
|
|
149
174
|
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]:
|
|
|
181
206
|
return validate_packages(packages)
|
|
182
207
|
|
|
183
208
|
|
|
209
|
+
def parse_env_arguments(
|
|
210
|
+
env_args: list[str] | None,
|
|
211
|
+
base_env: dict[str, str] | None = None,
|
|
212
|
+
) -> dict[str, str]:
|
|
213
|
+
"""Parse repeated --env arguments, resolving KEY=VALUE or KEY from host env."""
|
|
214
|
+
merged: dict[str, str] = dict(base_env or {})
|
|
215
|
+
if not env_args:
|
|
216
|
+
return validate_env(merged) if merged else {}
|
|
217
|
+
for item in env_args:
|
|
218
|
+
for entry in item.splitlines():
|
|
219
|
+
entry = entry.strip()
|
|
220
|
+
if not entry:
|
|
221
|
+
continue
|
|
222
|
+
if "=" in entry:
|
|
223
|
+
key, val = entry.split("=", 1)
|
|
224
|
+
else:
|
|
225
|
+
key = entry
|
|
226
|
+
val = os.environ.get(key, "")
|
|
227
|
+
key = key.strip()
|
|
228
|
+
if not ENV_KEY_RE.fullmatch(key):
|
|
229
|
+
raise ValueError(
|
|
230
|
+
f"Invalid environment variable name: '{key}'. Must be a valid POSIX identifier (^[A-Za-z_][A-Za-z0-9_]*$)"
|
|
231
|
+
)
|
|
232
|
+
merged[key] = val
|
|
233
|
+
return validate_env(merged)
|
|
234
|
+
|
|
235
|
+
|
|
184
236
|
def run_checked_matrix(
|
|
185
237
|
args: argparse.Namespace, script_path: str, matrix: list[str]
|
|
186
238
|
) -> RunReport | None:
|
|
@@ -197,6 +249,8 @@ def run_checked_matrix(
|
|
|
197
249
|
network=args.network,
|
|
198
250
|
packages=args.packages,
|
|
199
251
|
repeat=args.repeat,
|
|
252
|
+
env=args.env,
|
|
253
|
+
workdir=args.workdir,
|
|
200
254
|
)
|
|
201
255
|
except DockerDaemonError as err:
|
|
202
256
|
sys.stderr.write(f"Docker Error: {err}\n")
|
|
@@ -207,8 +261,11 @@ def run_checked_matrix(
|
|
|
207
261
|
|
|
208
262
|
def _output_overwrites_protected(output_path: Path, protected: list[Path]) -> bool:
|
|
209
263
|
"""Reject paths that resolve to or share an inode with a protected file."""
|
|
210
|
-
|
|
211
|
-
|
|
264
|
+
for p in protected:
|
|
265
|
+
if output_path == p:
|
|
266
|
+
return True
|
|
267
|
+
if os.name == "nt" and str(output_path).lower() == str(p).lower():
|
|
268
|
+
return True
|
|
212
269
|
if not output_path.exists():
|
|
213
270
|
return False
|
|
214
271
|
for target in protected:
|
|
@@ -316,6 +373,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
316
373
|
args.packages = parse_packages_argument(args.packages)
|
|
317
374
|
if args.packages and args.network == "none":
|
|
318
375
|
raise ValueError("packages require network=bridge")
|
|
376
|
+
base_env = config.get("env") if isinstance(config.get("env"), dict) else {}
|
|
377
|
+
args.env = parse_env_arguments(args.env, base_env=base_env)
|
|
378
|
+
if args.workdir:
|
|
379
|
+
args.workdir = validate_workdir(args.workdir)
|
|
319
380
|
if args.matrix is not None and not any(x.strip() for x in args.matrix.split(",")):
|
|
320
381
|
raise ValueError("matrix must contain at least one image")
|
|
321
382
|
except ValueError as err:
|
|
@@ -385,14 +446,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
385
446
|
plan = {"dry_run": True, "scripts": target_scripts, "matrix": matrix,
|
|
386
447
|
"executions": len(target_scripts) * len(matrix) * args.repeat, "shell": args.shell,
|
|
387
448
|
"network": args.network, "packages": args.packages, "timeout": args.timeout,
|
|
388
|
-
"repeat": args.repeat}
|
|
449
|
+
"repeat": args.repeat, "env": args.env, "workdir": args.workdir}
|
|
389
450
|
if changed_selection:
|
|
390
451
|
plan["changed_since"] = args.changed_since
|
|
452
|
+
env_preview = f"\nEnv: {', '.join(f'{k}={v}' for k, v in args.env.items())}" if args.env else ""
|
|
453
|
+
workdir_preview = f"\nWorkdir: {args.workdir}" if args.workdir else ""
|
|
391
454
|
output = json.dumps(plan, indent=2) if args.format == "json" else (
|
|
392
455
|
"Execution preview (no scripts executed)\n"
|
|
393
456
|
+ "\n".join(f" {path}" for path in target_scripts)
|
|
394
457
|
+ f"\nImages: {', '.join(matrix)}\nContainer executions: {plan['executions']}"
|
|
395
458
|
+ (f"\nPackages: {', '.join(args.packages)}" if args.packages else "")
|
|
459
|
+
+ env_preview
|
|
460
|
+
+ workdir_preview
|
|
396
461
|
+ f"\nShell: {args.shell} | Network: {args.network} | Timeout: {args.timeout}s | Repeat: {args.repeat}"
|
|
397
462
|
)
|
|
398
463
|
return 0 if publish_output(output, args.output) else 1
|
|
@@ -17,7 +17,7 @@ DEFAULTS = {
|
|
|
17
17
|
"matrix": None, "preset": None, "jobs": None, "timeout": 60,
|
|
18
18
|
"shell": "posix", "mem_limit": "256m", "pids_limit": 128,
|
|
19
19
|
"network": "bridge", "packages": [], "exclude": [], "max_scripts": 20,
|
|
20
|
-
"repeat": 1,
|
|
20
|
+
"repeat": 1, "env": {}, "workdir": None,
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
MAX_CONFIG_SIZE_BYTES = 256 * 1024
|
|
@@ -26,6 +26,39 @@ MAX_TIMEOUT_SECONDS = 3600
|
|
|
26
26
|
MAX_MATRIX_IMAGES = 32
|
|
27
27
|
|
|
28
28
|
PACKAGE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+._:-]*$")
|
|
29
|
+
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_env(value: object) -> dict[str, str]:
|
|
33
|
+
"""Validate and normalize environment variables specified in configuration or CLI."""
|
|
34
|
+
if not isinstance(value, dict):
|
|
35
|
+
raise ValueError("env must be a JSON object mapping variable names to string values")
|
|
36
|
+
normalized: dict[str, str] = {}
|
|
37
|
+
for k, v in value.items():
|
|
38
|
+
if not isinstance(k, str) or not ENV_KEY_RE.fullmatch(k):
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"Invalid environment variable name '{k}'. Must be a valid POSIX identifier (^[A-Za-z_][A-Za-z0-9_]*$)"
|
|
41
|
+
)
|
|
42
|
+
if not isinstance(v, str):
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Environment variable '{k}' value must be a string, got {type(v).__name__}"
|
|
45
|
+
)
|
|
46
|
+
normalized[k] = v
|
|
47
|
+
return normalized
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_workdir(value: object) -> str:
|
|
51
|
+
"""Validate that the workspace directory exists, is a directory, and is not root."""
|
|
52
|
+
if not isinstance(value, str) or not value.strip():
|
|
53
|
+
raise ValueError("workdir must be a nonempty path string")
|
|
54
|
+
path = Path(value.strip()).resolve()
|
|
55
|
+
if not path.exists():
|
|
56
|
+
raise ValueError(f"workdir directory does not exist: {value}")
|
|
57
|
+
if not path.is_dir():
|
|
58
|
+
raise ValueError(f"workdir is not a directory: {value}")
|
|
59
|
+
if path == path.parent:
|
|
60
|
+
raise ValueError(f"workdir must not be the root directory: {value}")
|
|
61
|
+
return str(path)
|
|
29
62
|
|
|
30
63
|
|
|
31
64
|
def validate_memory_limit(value: object) -> str:
|
|
@@ -93,6 +126,10 @@ def load_config(filename: str | None) -> dict:
|
|
|
93
126
|
validate_packages(value)
|
|
94
127
|
elif key == "mem_limit":
|
|
95
128
|
validate_memory_limit(value)
|
|
129
|
+
elif key == "env":
|
|
130
|
+
validate_env(value)
|
|
131
|
+
elif key == "workdir":
|
|
132
|
+
validate_workdir(value)
|
|
96
133
|
elif not isinstance(value, str) or not value.strip():
|
|
97
134
|
raise ValueError(f"{key} must be a nonempty string")
|
|
98
135
|
for key, choices in {"shell": ("posix", "auto", "shebang"),
|
|
@@ -126,7 +163,7 @@ jobs:
|
|
|
126
163
|
- uses: actions/checkout@v7
|
|
127
164
|
with:
|
|
128
165
|
persist-credentials: false
|
|
129
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
166
|
+
- uses: Mresyzz/opsscript-gate@v0.9.6
|
|
130
167
|
with:
|
|
131
168
|
config: .opsscript-gate.json
|
|
132
169
|
""",
|
|
@@ -123,6 +123,16 @@ COMMON_COMMAND_PACKAGES: dict[str, dict[str, str]] = {
|
|
|
123
123
|
"envsubst": {"debian": "gettext-base", "alpine": "gettext", "rhel": "gettext"},
|
|
124
124
|
"crontab": {"debian": "cron", "alpine": "dcron", "rhel": "cronie"},
|
|
125
125
|
"ps": {"debian": "procps", "alpine": "procps", "rhel": "procps-ng"},
|
|
126
|
+
"find": {"debian": "findutils", "alpine": "findutils", "rhel": "findutils"},
|
|
127
|
+
"xargs": {"debian": "findutils", "alpine": "findutils", "rhel": "findutils"},
|
|
128
|
+
"grep": {"debian": "grep", "alpine": "grep", "rhel": "grep"},
|
|
129
|
+
"sed": {"debian": "sed", "alpine": "sed", "rhel": "sed"},
|
|
130
|
+
"awk": {"debian": "gawk", "alpine": "gawk", "rhel": "gawk"},
|
|
131
|
+
"diff": {"debian": "diffutils", "alpine": "diffutils", "rhel": "diffutils"},
|
|
132
|
+
"patch": {"debian": "patch", "alpine": "patch", "rhel": "patch"},
|
|
133
|
+
"ssh": {"debian": "openssh-client", "alpine": "openssh-client", "rhel": "openssh-clients"},
|
|
134
|
+
"rsync": {"debian": "rsync", "alpine": "rsync", "rhel": "rsync"},
|
|
135
|
+
"htop": {"debian": "htop", "alpine": "htop", "rhel": "htop"},
|
|
126
136
|
}
|
|
127
137
|
|
|
128
138
|
|
|
@@ -16,7 +16,17 @@ from docker.errors import DockerException, ImageNotFound
|
|
|
16
16
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
17
17
|
from dataclasses import dataclass
|
|
18
18
|
from enum import Enum
|
|
19
|
-
from opsscript_gate.config import
|
|
19
|
+
from opsscript_gate.config import (
|
|
20
|
+
ENV_KEY_RE,
|
|
21
|
+
MAX_MATRIX_IMAGES,
|
|
22
|
+
MAX_REPEAT_ATTEMPTS,
|
|
23
|
+
MAX_TIMEOUT_SECONDS,
|
|
24
|
+
PACKAGE_NAME_RE,
|
|
25
|
+
validate_env,
|
|
26
|
+
validate_memory_limit,
|
|
27
|
+
validate_packages,
|
|
28
|
+
validate_workdir,
|
|
29
|
+
)
|
|
20
30
|
from opsscript_gate.models import DistroStatus, FailureDiagnostic, RunReport, ShellMode, SingleResult
|
|
21
31
|
from opsscript_gate.remediation import generate_remediation_hint, resolve_package_recommendation
|
|
22
32
|
|
|
@@ -804,6 +814,8 @@ def _runtime_option_error(
|
|
|
804
814
|
pids_limit: int,
|
|
805
815
|
network: str,
|
|
806
816
|
packages: Sequence[str],
|
|
817
|
+
env: dict[str, str] | None = None,
|
|
818
|
+
workdir: str | None = None,
|
|
807
819
|
) -> str | None:
|
|
808
820
|
"""Validate execution options shared by the public runner entry points."""
|
|
809
821
|
if isinstance(packages, (str, bytes)):
|
|
@@ -823,6 +835,10 @@ def _runtime_option_error(
|
|
|
823
835
|
try:
|
|
824
836
|
validate_memory_limit(mem_limit)
|
|
825
837
|
validate_packages(list(packages))
|
|
838
|
+
if env is not None:
|
|
839
|
+
validate_env(env)
|
|
840
|
+
if workdir is not None:
|
|
841
|
+
validate_workdir(workdir)
|
|
826
842
|
except (TypeError, ValueError) as exc:
|
|
827
843
|
return str(exc)
|
|
828
844
|
return None
|
|
@@ -859,6 +875,8 @@ def run_on_distro(
|
|
|
859
875
|
network: str = "bridge",
|
|
860
876
|
packages: Sequence[str] | None = None,
|
|
861
877
|
prepared_script_path: str | None = None,
|
|
878
|
+
env: dict[str, str] | None = None,
|
|
879
|
+
workdir: str | None = None,
|
|
862
880
|
) -> SingleResult:
|
|
863
881
|
"""
|
|
864
882
|
Run a target script inside an unprivileged, non-interactive container.
|
|
@@ -874,7 +892,7 @@ def run_on_distro(
|
|
|
874
892
|
error_message="packages must be a list of package names",
|
|
875
893
|
)
|
|
876
894
|
package_list = list(packages or [])
|
|
877
|
-
option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list)
|
|
895
|
+
option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list, env=env, workdir=workdir)
|
|
878
896
|
if option_error:
|
|
879
897
|
return SingleResult(
|
|
880
898
|
distro=distro,
|
|
@@ -988,10 +1006,20 @@ def run_on_distro(
|
|
|
988
1006
|
"mode": "ro",
|
|
989
1007
|
}
|
|
990
1008
|
}
|
|
1009
|
+
if workdir is not None:
|
|
1010
|
+
safe_workdir = normalize_host_path_for_docker(os.path.abspath(workdir))
|
|
1011
|
+
volumes[safe_workdir] = {
|
|
1012
|
+
"bind": "/workspace",
|
|
1013
|
+
"mode": "ro",
|
|
1014
|
+
}
|
|
1015
|
+
|
|
991
1016
|
environment = {
|
|
992
1017
|
"DEBIAN_FRONTEND": "noninteractive",
|
|
993
1018
|
"CI": "true",
|
|
994
1019
|
}
|
|
1020
|
+
if env:
|
|
1021
|
+
environment.update(env)
|
|
1022
|
+
|
|
995
1023
|
create_kwargs = {
|
|
996
1024
|
"image": distro,
|
|
997
1025
|
"command": container_command,
|
|
@@ -1014,6 +1042,8 @@ def run_on_distro(
|
|
|
1014
1042
|
),
|
|
1015
1043
|
"detach": True,
|
|
1016
1044
|
}
|
|
1045
|
+
if workdir is not None:
|
|
1046
|
+
create_kwargs["working_dir"] = "/workspace"
|
|
1017
1047
|
try:
|
|
1018
1048
|
container = client.containers.create(**create_kwargs)
|
|
1019
1049
|
except ImageNotFound:
|
|
@@ -1188,6 +1218,8 @@ def run_matrix(
|
|
|
1188
1218
|
network: str = "bridge",
|
|
1189
1219
|
packages: Sequence[str] | None = None,
|
|
1190
1220
|
repeat: int = 1,
|
|
1221
|
+
env: dict[str, str] | None = None,
|
|
1222
|
+
workdir: str | None = None,
|
|
1191
1223
|
) -> RunReport:
|
|
1192
1224
|
"""
|
|
1193
1225
|
Run the compatibility check across all specified Linux distributions,
|
|
@@ -1247,7 +1279,7 @@ def run_matrix(
|
|
|
1247
1279
|
if isinstance(packages, (str, bytes)):
|
|
1248
1280
|
raise ValueError("packages must be a list of package names")
|
|
1249
1281
|
package_list = list(packages or [])
|
|
1250
|
-
option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list)
|
|
1282
|
+
option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list, env=env, workdir=workdir)
|
|
1251
1283
|
if option_error:
|
|
1252
1284
|
raise ValueError(option_error)
|
|
1253
1285
|
package_list = validate_packages(package_list)
|
|
@@ -1299,6 +1331,8 @@ def run_matrix(
|
|
|
1299
1331
|
network=network,
|
|
1300
1332
|
packages=package_list,
|
|
1301
1333
|
prepared_script_path=prepared_script,
|
|
1334
|
+
env=env,
|
|
1335
|
+
workdir=workdir,
|
|
1302
1336
|
))
|
|
1303
1337
|
|
|
1304
1338
|
if repeat == 1:
|
|
@@ -9,12 +9,12 @@ ROOT = Path(__file__).parents[1]
|
|
|
9
9
|
def test_action_metadata_is_valid_and_inputs_are_wired():
|
|
10
10
|
text = (ROOT / "action.yml").read_text(encoding="utf-8")
|
|
11
11
|
assert "using: 'composite'" in text
|
|
12
|
-
for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since", "packages", "repeat"):
|
|
12
|
+
for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since", "packages", "repeat", "env", "workdir"):
|
|
13
13
|
assert f" {name}:" in text
|
|
14
14
|
run_text = text
|
|
15
15
|
for flag in ("--timeout", "--format", "--shell", "--mem-limit", "--pids-limit",
|
|
16
16
|
"--network", "--packages", "--matrix", "--jobs", "--config", "--preset", "--repeat",
|
|
17
|
-
"--max-scripts", "--output", "--exclude"):
|
|
17
|
+
"--max-scripts", "--output", "--exclude", "--env", "--workdir"):
|
|
18
18
|
assert flag in run_text
|
|
19
19
|
assert "json, or sarif" in text
|
|
20
20
|
assert "--changed-since" in run_text
|
|
@@ -721,3 +721,209 @@ def test_reporter_rendering_suggested_package_and_new_diagnostics():
|
|
|
721
721
|
props = sarif["runs"][0]["results"][0]["properties"]
|
|
722
722
|
assert props["diagnosticKind"] == "permission_denied"
|
|
723
723
|
|
|
724
|
+
|
|
725
|
+
# ==============================================================================
|
|
726
|
+
# 11. 环境变量安全注入与工作空间目录挂载专项测试 (v0.9.6)
|
|
727
|
+
# ==============================================================================
|
|
728
|
+
|
|
729
|
+
def test_validate_env_valid_and_invalid():
|
|
730
|
+
from opsscript_gate.config import validate_env
|
|
731
|
+
|
|
732
|
+
# 合法情况
|
|
733
|
+
assert validate_env({"FOO": "bar", "_BAZ_123": "val"}) == {"FOO": "bar", "_BAZ_123": "val"}
|
|
734
|
+
assert validate_env({}) == {}
|
|
735
|
+
|
|
736
|
+
# 非字典拒绝
|
|
737
|
+
with pytest.raises(ValueError, match="env must be a JSON object"):
|
|
738
|
+
validate_env(["FOO=bar"])
|
|
739
|
+
|
|
740
|
+
# 非法 key 命名拒绝
|
|
741
|
+
with pytest.raises(ValueError, match="Invalid environment variable name '123FOO'"):
|
|
742
|
+
validate_env({"123FOO": "bar"})
|
|
743
|
+
|
|
744
|
+
with pytest.raises(ValueError, match="Invalid environment variable name 'FOO-BAR'"):
|
|
745
|
+
validate_env({"FOO-BAR": "bar"})
|
|
746
|
+
|
|
747
|
+
# 非字符串 value 拒绝
|
|
748
|
+
with pytest.raises(ValueError, match="Environment variable 'FOO' value must be a string"):
|
|
749
|
+
validate_env({"FOO": 123})
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def test_validate_workdir_valid_and_invalid(tmp_path):
|
|
753
|
+
from opsscript_gate.config import validate_workdir
|
|
754
|
+
|
|
755
|
+
# 合法目录
|
|
756
|
+
sub_dir = tmp_path / "my_workspace"
|
|
757
|
+
sub_dir.mkdir()
|
|
758
|
+
assert validate_workdir(str(sub_dir)) == str(sub_dir.resolve())
|
|
759
|
+
|
|
760
|
+
# 不存在目录拒绝
|
|
761
|
+
with pytest.raises(ValueError, match="workdir directory does not exist"):
|
|
762
|
+
validate_workdir(str(tmp_path / "non_existent"))
|
|
763
|
+
|
|
764
|
+
# 指向普通文件拒绝
|
|
765
|
+
file_path = tmp_path / "regular.sh"
|
|
766
|
+
file_path.write_text("#!/bin/sh\n")
|
|
767
|
+
with pytest.raises(ValueError, match="workdir is not a directory"):
|
|
768
|
+
validate_workdir(str(file_path))
|
|
769
|
+
|
|
770
|
+
# 空或非字符串拒绝
|
|
771
|
+
with pytest.raises(ValueError, match="workdir must be a nonempty path string"):
|
|
772
|
+
validate_workdir("")
|
|
773
|
+
with pytest.raises(ValueError, match="workdir must be a nonempty path string"):
|
|
774
|
+
validate_workdir(123)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def test_config_env_and_workdir_support(tmp_path):
|
|
778
|
+
from opsscript_gate.config import load_config
|
|
779
|
+
import json
|
|
780
|
+
|
|
781
|
+
ws = tmp_path / "ws"
|
|
782
|
+
ws.mkdir()
|
|
783
|
+
cfg_file = tmp_path / ".opsscript-gate.json"
|
|
784
|
+
cfg_file.write_text(json.dumps({
|
|
785
|
+
"env": {"APP_PORT": "8080", "APP_ENV": "staging"},
|
|
786
|
+
"workdir": str(ws),
|
|
787
|
+
}))
|
|
788
|
+
|
|
789
|
+
loaded = load_config(str(cfg_file))
|
|
790
|
+
assert loaded["env"] == {"APP_PORT": "8080", "APP_ENV": "staging"}
|
|
791
|
+
assert loaded["workdir"] == str(ws.resolve())
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def test_parse_env_arguments_cli(monkeypatch):
|
|
795
|
+
from opsscript_gate.cli import parse_env_arguments
|
|
796
|
+
|
|
797
|
+
monkeypatch.setenv("EXISTING_VAR", "host_value")
|
|
798
|
+
|
|
799
|
+
# KEY=VALUE 格式
|
|
800
|
+
res = parse_env_arguments(["FOO=123", "BAR=abc=def"])
|
|
801
|
+
assert res == {"FOO": "123", "BAR": "abc=def"}
|
|
802
|
+
|
|
803
|
+
# 单独 KEY(从宿主机环境变量获取)
|
|
804
|
+
res_host = parse_env_arguments(["EXISTING_VAR", "NON_EXISTING"])
|
|
805
|
+
assert res_host["EXISTING_VAR"] == "host_value"
|
|
806
|
+
assert res_host["NON_EXISTING"] == ""
|
|
807
|
+
|
|
808
|
+
# 多行输入
|
|
809
|
+
res_multiline = parse_env_arguments(["A=1\nB=2\n\nC=3"])
|
|
810
|
+
assert res_multiline == {"A": "1", "B": "2", "C": "3"}
|
|
811
|
+
|
|
812
|
+
# 优先级合并:命令行覆盖 base_env
|
|
813
|
+
res_merge = parse_env_arguments(["A=cli_val", "B=2"], base_env={"A": "base_val", "C": "3"})
|
|
814
|
+
assert res_merge == {"A": "cli_val", "B": "2", "C": "3"}
|
|
815
|
+
|
|
816
|
+
# 非法名称抛错
|
|
817
|
+
with pytest.raises(ValueError, match="Invalid environment variable name: 'BAD-NAME'"):
|
|
818
|
+
parse_env_arguments(["BAD-NAME=1"])
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def test_cli_output_overwrites_protected_case_insensitive(tmp_path, monkeypatch):
|
|
822
|
+
from opsscript_gate.cli import _output_overwrites_protected
|
|
823
|
+
from pathlib import Path
|
|
824
|
+
|
|
825
|
+
file_a = tmp_path / "README.md"
|
|
826
|
+
file_a.write_text("hello")
|
|
827
|
+
|
|
828
|
+
# 同一路径
|
|
829
|
+
assert _output_overwrites_protected(file_a, [file_a]) is True
|
|
830
|
+
|
|
831
|
+
# 验证在 Windows 平台下大小写不敏感碰撞保护
|
|
832
|
+
monkeypatch.setattr("os.name", "nt")
|
|
833
|
+
file_lower = tmp_path / "readme.md"
|
|
834
|
+
assert _output_overwrites_protected(file_lower, [file_a]) is True
|
|
835
|
+
|
|
836
|
+
# 未受保护的文件
|
|
837
|
+
file_other = tmp_path / "other.txt"
|
|
838
|
+
assert _output_overwrites_protected(file_other, [file_a]) is False
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def test_run_matrix_with_env_and_workdir_mock(tmp_path):
|
|
842
|
+
from opsscript_gate.runner import run_matrix
|
|
843
|
+
from unittest.mock import MagicMock
|
|
844
|
+
|
|
845
|
+
script = tmp_path / "test.sh"
|
|
846
|
+
script.write_text("#!/bin/sh\necho ok\n")
|
|
847
|
+
|
|
848
|
+
ws = tmp_path / "my_workdir"
|
|
849
|
+
ws.mkdir()
|
|
850
|
+
|
|
851
|
+
mock_client = MagicMock()
|
|
852
|
+
mock_container = MagicMock()
|
|
853
|
+
mock_container.wait.return_value = {"StatusCode": 0}
|
|
854
|
+
mock_container.logs.return_value = b"ok\n"
|
|
855
|
+
mock_container.attrs = {"State": {"ExitCode": 0}}
|
|
856
|
+
mock_client.containers.create.return_value = mock_container
|
|
857
|
+
|
|
858
|
+
report = run_matrix(
|
|
859
|
+
script_path=str(script),
|
|
860
|
+
matrix=["debian:12-slim"],
|
|
861
|
+
client=mock_client,
|
|
862
|
+
env={"TEST_KEY": "test_val"},
|
|
863
|
+
workdir=str(ws),
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
assert report.all_passed is True
|
|
867
|
+
create_call = mock_client.containers.create.call_args[1]
|
|
868
|
+
|
|
869
|
+
# 验证环境变量合并
|
|
870
|
+
assert create_call["environment"]["TEST_KEY"] == "test_val"
|
|
871
|
+
assert create_call["environment"]["CI"] == "true"
|
|
872
|
+
assert create_call["environment"]["DEBIAN_FRONTEND"] == "noninteractive"
|
|
873
|
+
|
|
874
|
+
# 验证工作区只读挂载与工作目录配置
|
|
875
|
+
assert create_call["working_dir"] == "/workspace"
|
|
876
|
+
found_ws_mount = False
|
|
877
|
+
for host_vol, cfg in create_call["volumes"].items():
|
|
878
|
+
if cfg["bind"] == "/workspace":
|
|
879
|
+
assert cfg["mode"] == "ro"
|
|
880
|
+
found_ws_mount = True
|
|
881
|
+
assert found_ws_mount is True
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def test_cli_dry_run_displays_env_and_workdir(tmp_path, capsys):
|
|
885
|
+
from opsscript_gate.cli import main
|
|
886
|
+
import json
|
|
887
|
+
|
|
888
|
+
script = tmp_path / "script.sh"
|
|
889
|
+
script.write_text("#!/bin/sh\n")
|
|
890
|
+
|
|
891
|
+
ws = tmp_path / "workspace"
|
|
892
|
+
ws.mkdir()
|
|
893
|
+
|
|
894
|
+
code = main([
|
|
895
|
+
"run",
|
|
896
|
+
str(script),
|
|
897
|
+
"--dry-run",
|
|
898
|
+
"--format", "json",
|
|
899
|
+
"--env", "VAR_A=1",
|
|
900
|
+
"--workdir", str(ws),
|
|
901
|
+
])
|
|
902
|
+
assert code == 0
|
|
903
|
+
captured = capsys.readouterr().out
|
|
904
|
+
plan = json.loads(captured)
|
|
905
|
+
assert plan["env"] == {"VAR_A": "1"}
|
|
906
|
+
assert plan["workdir"] == str(ws.resolve())
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def test_expanded_remediation_packages():
|
|
910
|
+
from opsscript_gate.remediation import resolve_package_recommendation
|
|
911
|
+
|
|
912
|
+
for cmd, deb_expected, alp_expected in [
|
|
913
|
+
("find", "findutils", "findutils"),
|
|
914
|
+
("xargs", "findutils", "findutils"),
|
|
915
|
+
("grep", "grep", "grep"),
|
|
916
|
+
("sed", "sed", "sed"),
|
|
917
|
+
("awk", "gawk", "gawk"),
|
|
918
|
+
("diff", "diffutils", "diffutils"),
|
|
919
|
+
("patch", "patch", "patch"),
|
|
920
|
+
("ssh", "openssh-client", "openssh-client"),
|
|
921
|
+
("rsync", "rsync", "rsync"),
|
|
922
|
+
("htop", "htop", "htop"),
|
|
923
|
+
]:
|
|
924
|
+
pkg_deb, _ = resolve_package_recommendation(cmd, "debian:12-slim")
|
|
925
|
+
assert pkg_deb == deb_expected, f"Failed for debian {cmd}"
|
|
926
|
+
pkg_alp, _ = resolve_package_recommendation(cmd, "alpine:3.20")
|
|
927
|
+
assert pkg_alp == alp_expected, f"Failed for alpine {cmd}"
|
|
928
|
+
|
|
929
|
+
|
|
@@ -81,7 +81,9 @@ def test_init_existing_workflow_leaves_config_absent(project):
|
|
|
81
81
|
{"jobs": 0}, {"exclude": "tests/*"}, {"shell": "fish"}, {"network": "host"},
|
|
82
82
|
{"preset": "unknown"}, {"matrix": "alpine", "preset": "minimal"},
|
|
83
83
|
{"matrix": ","}, {"mem_limit": "0"}, {"mem_limit": "unlimited"}, {"repeat": 21}, {"timeout": 3601},
|
|
84
|
-
{"packages": "curl"}, {"packages": ["curl; touch pwned"]}
|
|
84
|
+
{"packages": "curl"}, {"packages": ["curl; touch pwned"]},
|
|
85
|
+
{"env": "not_a_dict"}, {"env": {"1_BAD": "val"}}, {"env": {"OK": 123}},
|
|
86
|
+
{"workdir": 123}, {"workdir": ""}])
|
|
85
87
|
def test_invalid_config(project, config):
|
|
86
88
|
Path(CONFIG_NAME).write_text(json.dumps(config))
|
|
87
89
|
with patch("opsscript_gate.cli.run_matrix") as run:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/alpine-incompatibility/apt_dependency.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|