opsscript-gate 0.1.2__py3-none-any.whl

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.

Potentially problematic release.


This version of opsscript-gate might be problematic. Click here for more details.

@@ -0,0 +1,4 @@
1
+ """OpsScript Gate: Lightweight cross-distro compatibility pre-check for Linux ops scripts."""
2
+
3
+ __version__ = "0.1.2"
4
+ __all__ = ["__version__"]
opsscript_gate/cli.py ADDED
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+ from opsscript_gate import __version__
8
+ from opsscript_gate.reporter import (
9
+ format_github_summary,
10
+ format_json,
11
+ format_terminal_table,
12
+ write_github_step_summary,
13
+ )
14
+ from opsscript_gate.runner import (
15
+ DEFAULT_MATRIX,
16
+ DEFAULT_TIMEOUT,
17
+ DockerDaemonError,
18
+ run_matrix,
19
+ )
20
+
21
+
22
+ def build_parser() -> argparse.ArgumentParser:
23
+ """Build the command-line argument parser."""
24
+ parser = argparse.ArgumentParser(
25
+ prog="opsscript-gate",
26
+ description="Lightweight unprivileged container cross-distro compatibility pre-check for Linux ops scripts.",
27
+ )
28
+ parser.add_argument(
29
+ "--version",
30
+ action="version",
31
+ version=f"%(prog)s {__version__}",
32
+ )
33
+
34
+ subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
35
+
36
+ # 'run' subcommand
37
+ run_parser = subparsers.add_parser(
38
+ "run",
39
+ help="Run cross-distro compatibility validation on a shell script",
40
+ )
41
+ run_parser.add_argument(
42
+ "script_path",
43
+ type=str,
44
+ help="Path to the target shell script to validate",
45
+ )
46
+ run_parser.add_argument(
47
+ "--matrix",
48
+ type=str,
49
+ default=None,
50
+ help=(
51
+ "Comma-separated list of container images to test against. "
52
+ f"Defaults to: {','.join(DEFAULT_MATRIX)}"
53
+ ),
54
+ )
55
+ run_parser.add_argument(
56
+ "--timeout",
57
+ type=int,
58
+ default=DEFAULT_TIMEOUT,
59
+ help=f"Hard timeout in seconds per container (default: {DEFAULT_TIMEOUT}s)",
60
+ )
61
+ run_parser.add_argument(
62
+ "--format",
63
+ choices=["table", "markdown", "json"],
64
+ default="table",
65
+ help="Output report format: 'table' (default ASCII), 'markdown', or 'json'",
66
+ )
67
+
68
+ return parser
69
+
70
+
71
+ def parse_matrix_argument(matrix_raw: str | None) -> list[str]:
72
+ """Parse comma-separated matrix string into a list of image names."""
73
+ if not matrix_raw:
74
+ return DEFAULT_MATRIX
75
+ images = [img.strip() for img in matrix_raw.split(",") if img.strip()]
76
+ return images if images else DEFAULT_MATRIX
77
+
78
+
79
+ def main(argv: list[str] | None = None) -> int:
80
+ """Main CLI entrypoint."""
81
+ parser = build_parser()
82
+ args = parser.parse_args(argv)
83
+
84
+ if not args.command:
85
+ parser.print_help()
86
+ return 1
87
+
88
+ if args.command == "run":
89
+ script_path = args.script_path
90
+ if not os.path.isfile(script_path):
91
+ sys.stderr.write(f"Error: Script file not found: {script_path}\n")
92
+ return 1
93
+
94
+ matrix = parse_matrix_argument(args.matrix)
95
+
96
+ try:
97
+ report = run_matrix(
98
+ script_path=script_path,
99
+ matrix=matrix,
100
+ timeout=args.timeout,
101
+ )
102
+ except DockerDaemonError as err:
103
+ sys.stderr.write(f"Docker Error: {err}\n")
104
+ return 1
105
+ except Exception as err:
106
+ sys.stderr.write(f"Unexpected Error: {err}\n")
107
+ return 1
108
+
109
+ # Format report output
110
+ if args.format == "json":
111
+ output = format_json(report)
112
+ elif args.format == "markdown":
113
+ output = format_github_summary(report)
114
+ else:
115
+ output = format_terminal_table(report)
116
+
117
+ print(output)
118
+
119
+ # Automatic GitHub Actions Step Summary injection
120
+ write_github_step_summary(report)
121
+
122
+ # Exit code convention: 0 for all pass, 1 for any failure/timeout/error
123
+ return 0 if report.all_passed else 1
124
+
125
+ return 0
126
+
127
+
128
+ if __name__ == "__main__":
129
+ sys.exit(main())
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+
8
+ class DistroStatus(str, Enum):
9
+ """Execution status for a specific Linux distribution."""
10
+ PASS = "PASS"
11
+ FAIL = "FAIL"
12
+ TIMED_OUT = "TIMED_OUT"
13
+ ERROR = "ERROR"
14
+
15
+
16
+ @dataclass
17
+ class SingleResult:
18
+ """Execution result for a single Linux distribution."""
19
+ distro: str
20
+ status: DistroStatus
21
+ exit_code: int | None
22
+ duration: float
23
+ output_snippet: str = ""
24
+ error_message: str | None = None
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ return {
28
+ "distro": self.distro,
29
+ "status": self.status.value,
30
+ "exit_code": self.exit_code,
31
+ "duration": round(self.duration, 3),
32
+ "output_snippet": self.output_snippet,
33
+ "error_message": self.error_message,
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class RunReport:
39
+ """Consolidated report across all tested distributions."""
40
+ results: list[SingleResult] = field(default_factory=list)
41
+ total_duration: float = 0.0
42
+ all_passed: bool = True
43
+
44
+ def __post_init__(self) -> None:
45
+ if self.results:
46
+ self.all_passed = all(r.status == DistroStatus.PASS for r in self.results)
47
+ else:
48
+ self.all_passed = True
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ return {
52
+ "results": [r.to_dict() for r in self.results],
53
+ "total_duration": round(self.total_duration, 3),
54
+ "all_passed": self.all_passed,
55
+ }
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from opsscript_gate.models import DistroStatus, RunReport, SingleResult
6
+
7
+
8
+ def format_terminal_table(report: RunReport) -> str:
9
+ """Format the report as a clean, aligned ASCII terminal table."""
10
+ headers = ["Distro", "Status", "Exit Code", "Duration", "Details"]
11
+
12
+ rows: list[list[str]] = []
13
+ for r in report.results:
14
+ exit_code_str = str(r.exit_code) if r.exit_code is not None else "-"
15
+ duration_str = f"{r.duration:.2f}s"
16
+
17
+ if r.status == DistroStatus.PASS:
18
+ detail = "OK"
19
+ elif r.error_message:
20
+ detail = r.error_message
21
+ else:
22
+ detail = "-"
23
+
24
+ rows.append([
25
+ r.distro,
26
+ r.status.value,
27
+ exit_code_str,
28
+ duration_str,
29
+ detail,
30
+ ])
31
+
32
+ col_widths = [len(h) for h in headers]
33
+ for row in rows:
34
+ for i, val in enumerate(row):
35
+ if len(val) > col_widths[i]:
36
+ col_widths[i] = len(val)
37
+
38
+ # Upper bound for detail column to keep terminal tidy
39
+ max_detail_width = 50
40
+ if col_widths[4] > max_detail_width:
41
+ col_widths[4] = max_detail_width
42
+
43
+ def format_row(values: list[str]) -> str:
44
+ formatted_vals = []
45
+ for i, v in enumerate(values):
46
+ w = col_widths[i]
47
+ if len(v) > w:
48
+ truncated = v[: w - 3] + "..."
49
+ formatted_vals.append(truncated.ljust(w))
50
+ else:
51
+ formatted_vals.append(v.ljust(w))
52
+ return "| " + " | ".join(formatted_vals) + " |"
53
+
54
+ sep_line = "+-" + "-+-".join("-" * w for w in col_widths) + "-+"
55
+
56
+ lines = [
57
+ sep_line,
58
+ format_row(headers),
59
+ sep_line,
60
+ ]
61
+ for row in rows:
62
+ lines.append(format_row(row))
63
+ lines.append(sep_line)
64
+
65
+ status_str = "PASSED" if report.all_passed else "FAILED"
66
+ lines.append(
67
+ f"Total duration: {report.total_duration:.2f}s | Result: {status_str}"
68
+ )
69
+
70
+ # Append output snippets for failing/timed out distros
71
+ failed_results = [r for r in report.results if r.status != DistroStatus.PASS and r.output_snippet]
72
+ if failed_results:
73
+ lines.append("\n" + "=" * 60)
74
+ lines.append("Failed Distributions - Output Snippets (last 15 lines):")
75
+ lines.append("=" * 60)
76
+ for r in failed_results:
77
+ lines.append(f"\n--- [{r.distro}] ({r.status.value}) ---")
78
+ lines.append(r.output_snippet)
79
+
80
+ return "\n".join(lines)
81
+
82
+
83
+ def format_github_summary(report: RunReport) -> str:
84
+ """Format the report as GitHub-flavored Markdown for Actions Step Summary."""
85
+ overall_badge = "✅ **ALL PASSED**" if report.all_passed else "❌ **CHECKS FAILED**"
86
+
87
+ lines = [
88
+ "## 🛡️ OpsScript Gate Compatibility Report",
89
+ "",
90
+ f"**Overall Status**: {overall_badge} ",
91
+ f"**Total Duration**: `{report.total_duration:.2f}s` ",
92
+ f"**Total Tested**: `{len(report.results)}`",
93
+ "",
94
+ "| Distro | Status | Exit Code | Duration | Message |",
95
+ "| :--- | :---: | :---: | :---: | :--- |",
96
+ ]
97
+
98
+ for r in report.results:
99
+ if r.status == DistroStatus.PASS:
100
+ status_icon = "✅ PASS"
101
+ elif r.status == DistroStatus.FAIL:
102
+ status_icon = "❌ FAIL"
103
+ elif r.status == DistroStatus.TIMED_OUT:
104
+ status_icon = "⏱️ TIMED_OUT"
105
+ else:
106
+ status_icon = "⚠️ ERROR"
107
+
108
+ exit_code_str = f"`{r.exit_code}`" if r.exit_code is not None else "`N/A`"
109
+ duration_str = f"`{r.duration:.2f}s`"
110
+ msg = r.error_message or "-"
111
+ msg_escaped = msg.replace("|", "\\|")
112
+
113
+ lines.append(
114
+ f"| `{r.distro}` | {status_icon} | {exit_code_str} | {duration_str} | {msg_escaped} |"
115
+ )
116
+
117
+ lines.append("")
118
+
119
+ # Expandable details for any failures or timeouts
120
+ failures = [r for r in report.results if r.status != DistroStatus.PASS]
121
+ if failures:
122
+ lines.append("### 🔍 Failure Diagnostic Logs")
123
+ for r in failures:
124
+ lines.append(f"<details><summary><b>[{r.status.value}] {r.distro}</b></summary>")
125
+ lines.append("")
126
+ if r.error_message:
127
+ lines.append(f"> **Error:** {r.error_message}")
128
+ lines.append("")
129
+ if r.output_snippet:
130
+ lines.append("```text")
131
+ lines.append(r.output_snippet)
132
+ lines.append("```")
133
+ else:
134
+ lines.append("*No output captured.*")
135
+ lines.append("</details>")
136
+ lines.append("")
137
+
138
+ return "\n".join(lines)
139
+
140
+
141
+ def format_json(report: RunReport) -> str:
142
+ """Format the report as structured JSON."""
143
+ return json.dumps(report.to_dict(), indent=2, ensure_ascii=False)
144
+
145
+
146
+ def write_github_step_summary(report: RunReport) -> bool:
147
+ """Write markdown report to $GITHUB_STEP_SUMMARY if present in environment."""
148
+ summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
149
+ if not summary_path:
150
+ return False
151
+
152
+ try:
153
+ markdown_content = format_github_summary(report)
154
+ with open(summary_path, "a", encoding="utf-8") as f:
155
+ f.write("\n" + markdown_content + "\n")
156
+ return True
157
+ except Exception:
158
+ return False
@@ -0,0 +1,292 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tempfile
5
+ import time
6
+ from typing import Sequence
7
+
8
+ import docker
9
+ from docker.errors import DockerException, ImageNotFound
10
+
11
+ from opsscript_gate.models import DistroStatus, RunReport, SingleResult
12
+
13
+ DEFAULT_MATRIX: list[str] = [
14
+ "debian:12-slim",
15
+ "ubuntu:22.04",
16
+ "ubuntu:24.04",
17
+ "alpine:3.20",
18
+ ]
19
+
20
+ DEFAULT_TIMEOUT: int = 60
21
+ SNIPPET_LINE_LIMIT: int = 15
22
+
23
+
24
+ class DockerDaemonError(RuntimeError):
25
+ """Raised when the Docker daemon is unreachable or not running."""
26
+ pass
27
+
28
+
29
+ def get_docker_client() -> docker.DockerClient:
30
+ """Connect to the Docker daemon with clear human-readable error handling."""
31
+ try:
32
+ client = docker.from_env()
33
+ client.ping()
34
+ return client
35
+ except DockerException as exc:
36
+ raise DockerDaemonError(
37
+ f"Cannot connect to Docker daemon: {exc}. "
38
+ "Please ensure Docker is installed, running, and accessible."
39
+ ) from exc
40
+ except Exception as exc:
41
+ raise DockerDaemonError(
42
+ f"Unexpected error connecting to Docker daemon: {exc}."
43
+ ) from exc
44
+
45
+
46
+ def extract_snippet(output: str, max_lines: int = SNIPPET_LINE_LIMIT) -> str:
47
+ """Extract the last max_lines of output."""
48
+ if not output:
49
+ return ""
50
+ lines = output.strip().splitlines()
51
+ if len(lines) <= max_lines:
52
+ return "\n".join(lines)
53
+ return "\n".join(lines[-max_lines:])
54
+
55
+
56
+ def normalize_host_path_for_docker(path: str) -> str:
57
+ """Format path to be safe for Docker volume mounting on both Windows and POSIX."""
58
+ abs_path = os.path.abspath(path)
59
+ # On Windows, Docker client handles forward slashes cleanly without colon misparsing
60
+ return abs_path.replace("\\", "/")
61
+
62
+
63
+ def prepare_script(script_path: str) -> tuple[str, tempfile.NamedTemporaryFile | None]:
64
+ """
65
+ Check and normalize line endings (CRLF -> LF) to defend against
66
+ '\\r: command not found' errors in Linux containers (especially Alpine).
67
+ Returns (path_to_mount, temp_file_or_none).
68
+ """
69
+ abs_path = os.path.abspath(script_path)
70
+ with open(abs_path, "rb") as f:
71
+ content = f.read()
72
+
73
+ if b"\r\n" in content:
74
+ # Normalize CRLF to LF in a temporary file
75
+ temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".sh")
76
+ temp_file.write(content.replace(b"\r\n", b"\n"))
77
+ temp_file.flush()
78
+ temp_file.close()
79
+ return temp_file.name, temp_file
80
+
81
+ return abs_path, None
82
+
83
+
84
+ def run_on_distro(
85
+ client: docker.DockerClient,
86
+ script_path: str,
87
+ distro: str,
88
+ timeout: int = DEFAULT_TIMEOUT,
89
+ poll_interval: float = 0.1,
90
+ ) -> SingleResult:
91
+ """
92
+ Run a target script inside an unprivileged, non-interactive container.
93
+ Strictly uses /bin/sh for 100% compatibility with Alpine, Debian, and Ubuntu.
94
+ """
95
+ abs_script = os.path.abspath(script_path)
96
+ if not os.path.isfile(abs_script):
97
+ return SingleResult(
98
+ distro=distro,
99
+ status=DistroStatus.ERROR,
100
+ exit_code=None,
101
+ duration=0.0,
102
+ output_snippet="",
103
+ error_message=f"Target script does not exist: {abs_script}",
104
+ )
105
+
106
+ # Line-ending defense & Windows-safe path preparation
107
+ temp_file = None
108
+ try:
109
+ mount_src, temp_file = prepare_script(abs_script)
110
+ except Exception as exc:
111
+ return SingleResult(
112
+ distro=distro,
113
+ status=DistroStatus.ERROR,
114
+ exit_code=None,
115
+ duration=0.0,
116
+ output_snippet="",
117
+ error_message=f"Failed to read/prepare script: {exc}",
118
+ )
119
+
120
+ safe_mount_src = normalize_host_path_for_docker(mount_src)
121
+
122
+ # Security: Strict unprivileged options and read-only mount
123
+ volumes = {
124
+ safe_mount_src: {
125
+ "bind": "/tmp/target_script.sh",
126
+ "mode": "ro",
127
+ }
128
+ }
129
+ environment = {
130
+ "DEBIAN_FRONTEND": "noninteractive",
131
+ "CI": "true",
132
+ }
133
+ # Red-line rule: strictly /bin/sh (never hardcode /bin/bash for Alpine compatibility)
134
+ # Redirect stdin from /dev/null to defend against interactive hangs
135
+ command = ["/bin/sh", "-c", "/bin/sh /tmp/target_script.sh </dev/null"]
136
+
137
+ container = None
138
+ start_time = time.perf_counter()
139
+
140
+ try:
141
+ try:
142
+ container = client.containers.create(
143
+ image=distro,
144
+ command=command,
145
+ volumes=volumes,
146
+ environment=environment,
147
+ stdin_open=False,
148
+ tty=False,
149
+ privileged=False,
150
+ cap_drop=["ALL"],
151
+ security_opt=["no-new-privileges:true"],
152
+ network_mode="bridge",
153
+ detach=True,
154
+ )
155
+ except ImageNotFound:
156
+ client.images.pull(distro)
157
+ container = client.containers.create(
158
+ image=distro,
159
+ command=command,
160
+ volumes=volumes,
161
+ environment=environment,
162
+ stdin_open=False,
163
+ tty=False,
164
+ privileged=False,
165
+ cap_drop=["ALL"],
166
+ security_opt=["no-new-privileges:true"],
167
+ network_mode="bridge",
168
+ detach=True,
169
+ )
170
+
171
+ container.start()
172
+
173
+ # Hard timeout monitoring with container.kill()
174
+ timed_out = False
175
+ while True:
176
+ elapsed = time.perf_counter() - start_time
177
+ if elapsed >= timeout:
178
+ timed_out = True
179
+ try:
180
+ container.kill()
181
+ except Exception:
182
+ pass
183
+ break
184
+
185
+ container.reload()
186
+ status_str = container.status.lower()
187
+ if status_str in ("exited", "dead", "stopped"):
188
+ break
189
+
190
+ time.sleep(poll_interval)
191
+
192
+ duration = time.perf_counter() - start_time
193
+
194
+ # Retrieve container logs
195
+ try:
196
+ raw_logs = container.logs(stdout=True, stderr=True)
197
+ output = raw_logs.decode("utf-8", errors="replace") if isinstance(raw_logs, bytes) else str(raw_logs)
198
+ except Exception:
199
+ output = ""
200
+
201
+ if timed_out:
202
+ return SingleResult(
203
+ distro=distro,
204
+ status=DistroStatus.TIMED_OUT,
205
+ exit_code=None,
206
+ duration=duration,
207
+ output_snippet=extract_snippet(output),
208
+ error_message=f"Execution timed out after {timeout} seconds (container killed)",
209
+ )
210
+
211
+ container.reload()
212
+ state = getattr(container, "attrs", {}).get("State", {})
213
+ exit_code = state.get("ExitCode")
214
+
215
+ if exit_code is None:
216
+ exit_code = 0 if container.status == "exited" else 1
217
+
218
+ if exit_code == 0:
219
+ return SingleResult(
220
+ distro=distro,
221
+ status=DistroStatus.PASS,
222
+ exit_code=0,
223
+ duration=duration,
224
+ output_snippet=extract_snippet(output) if output.strip() else "",
225
+ error_message=None,
226
+ )
227
+ else:
228
+ return SingleResult(
229
+ distro=distro,
230
+ status=DistroStatus.FAIL,
231
+ exit_code=exit_code,
232
+ duration=duration,
233
+ output_snippet=extract_snippet(output),
234
+ error_message=f"Script failed with non-zero exit code: {exit_code}",
235
+ )
236
+
237
+ except Exception as exc:
238
+ duration = time.perf_counter() - start_time
239
+ return SingleResult(
240
+ distro=distro,
241
+ status=DistroStatus.ERROR,
242
+ exit_code=None,
243
+ duration=duration,
244
+ output_snippet="",
245
+ error_message=f"Container execution error: {exc}",
246
+ )
247
+ finally:
248
+ # Zero-zombie guarantee: always remove container
249
+ if container is not None:
250
+ try:
251
+ container.remove(force=True)
252
+ except Exception:
253
+ pass
254
+ # Clean up temporary CRLF normalized file if created
255
+ if temp_file is not None:
256
+ try:
257
+ if os.path.exists(temp_file.name):
258
+ os.remove(temp_file.name)
259
+ except Exception:
260
+ pass
261
+
262
+
263
+ def run_matrix(
264
+ script_path: str,
265
+ matrix: Sequence[str] | None = None,
266
+ timeout: int = DEFAULT_TIMEOUT,
267
+ client: docker.DockerClient | None = None,
268
+ ) -> RunReport:
269
+ """Run the compatibility check across all specified Linux distributions."""
270
+ distro_list = list(matrix) if matrix else DEFAULT_MATRIX
271
+ docker_client = client or get_docker_client()
272
+
273
+ start_total = time.perf_counter()
274
+ results: list[SingleResult] = []
275
+
276
+ for distro in distro_list:
277
+ res = run_on_distro(
278
+ client=docker_client,
279
+ script_path=script_path,
280
+ distro=distro,
281
+ timeout=timeout,
282
+ )
283
+ results.append(res)
284
+
285
+ total_duration = time.perf_counter() - start_total
286
+ all_passed = all(r.status == DistroStatus.PASS for r in results) if results else True
287
+
288
+ return RunReport(
289
+ results=results,
290
+ total_duration=total_duration,
291
+ all_passed=all_passed,
292
+ )
@@ -0,0 +1,246 @@
1
+ Metadata-Version: 2.5
2
+ Name: opsscript-gate
3
+ Version: 0.1.2
4
+ Summary: Run Linux shell scripts across Debian, Ubuntu and Alpine before release to catch runtime compatibility failures
5
+ Project-URL: Homepage, https://github.com/Mresyzz/opsscript-gate
6
+ Project-URL: Repository, https://github.com/Mresyzz/opsscript-gate
7
+ Project-URL: Issues, https://github.com/Mresyzz/opsscript-gate/issues
8
+ Project-URL: Changelog, https://github.com/Mresyzz/opsscript-gate/blob/main/CHANGELOG.md
9
+ Author: Mresyzz
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: ci,compatibility,cross-distro,devops,docker,github-actions,linux,posix,shell,testing
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Classifier: Topic :: System :: Systems Administration
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: docker>=7.0.0
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=8.0.0; extra == 'test'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # OpsScript Gate
30
+
31
+ > **ShellCheck tells you if your script looks portable. OpsScript Gate checks if it actually runs there.**
32
+
33
+ [![CI](https://github.com/Mresyzz/opsscript-gate/actions/workflows/test.yml/badge.svg)](https://github.com/Mresyzz/opsscript-gate/actions/workflows/test.yml)
34
+ [![Demo](https://github.com/Mresyzz/opsscript-gate/actions/workflows/demo.yml/badge.svg)](https://github.com/Mresyzz/opsscript-gate/actions/workflows/demo.yml)
35
+ [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-OpsScript%20Gate-blue?logo=github&color=2088FF)](https://github.com/marketplace/actions/opsscript-gate)
36
+ [![Release](https://img.shields.io/github/v/release/Mresyzz/opsscript-gate?color=green)](https://github.com/Mresyzz/opsscript-gate/releases)
37
+ [![Python Version](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
39
+ [![Supported Distros](https://img.shields.io/badge/matrix-Debian%20%7C%20Ubuntu%20%7C%20Alpine-orange.svg)](#default-test-matrix)
40
+
41
+ **OpsScript Gate** is a drop-in runtime compatibility gate for Linux shell scripts. It executes your shell scripts inside isolated Debian, Ubuntu, and Alpine containers before release, catching environment-specific runtime failures that static analysis cannot detect.
42
+
43
+ <p align="center">
44
+ <img src="https://raw.githubusercontent.com/Mresyzz/opsscript-gate/main/.github/assets/social-preview.png" alt="OpsScript Gate Terminal Preview" width="800">
45
+ </p>
46
+
47
+ ---
48
+
49
+ ## Quickstart
50
+
51
+ ### In GitHub Actions
52
+
53
+ Add one step to your pull request workflow (`.github/workflows/gate.yml`):
54
+
55
+ ```yaml
56
+ - name: Verify Shell Script Portability
57
+ uses: Mresyzz/opsscript-gate@v0.1.1
58
+ with:
59
+ script-path: scripts/setup.sh
60
+ ```
61
+
62
+ ### In Local Terminal (CLI)
63
+
64
+ Requires Python 3.10+ and a local Docker engine:
65
+
66
+ ```bash
67
+ # Install directly from GitHub
68
+ pip install git+https://github.com/Mresyzz/opsscript-gate.git
69
+
70
+ # Run compatibility gate against your script
71
+ opsscript-gate run ./scripts/setup.sh
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Example: What Static Analysis Misses
77
+
78
+ Consider this deployment script:
79
+
80
+ ```bash
81
+ #!/bin/sh
82
+ set -e
83
+ echo "Fetching package information..."
84
+ apt-get --version
85
+ ```
86
+
87
+ Running `shellcheck` reports **0 errors, 0 warnings** because the syntax is valid POSIX shell.
88
+
89
+ However, when verified with **OpsScript Gate**:
90
+
91
+ ```text
92
+ +--------------------+----------+-----------+----------------------------------------------------+
93
+ | Distro | Status | Exit Code | Details |
94
+ +--------------------+----------+-----------+----------------------------------------------------+
95
+ | debian:12-slim | PASS | 0 | OK |
96
+ | ubuntu:22.04 | PASS | 0 | OK |
97
+ | ubuntu:24.04 | PASS | 0 | OK |
98
+ | alpine:3.20 | FAIL | 127 | Script failed with non-zero exit code: 127 |
99
+ +--------------------+----------+-----------+----------------------------------------------------+
100
+ Result: FAILED
101
+
102
+ ============================================================
103
+ Failed Distributions - Output Snippets (last 15 lines):
104
+ ============================================================
105
+
106
+ --- [alpine:3.20] (FAIL) ---
107
+ /tmp/target_script.sh: line 4: apt-get: not found
108
+ ```
109
+
110
+ *Example output; timing values omitted because they vary by host and image cache state.*
111
+
112
+ **Why it failed:** Alpine Linux is musl/BusyBox-based and uses `apk`, not `apt-get`. OpsScript Gate catches the missing utility (`exit code 127`) during test execution, before the script is deployed.
113
+
114
+ ---
115
+
116
+ ## Why OpsScript Gate?
117
+
118
+ ### OpsScript Gate vs ShellCheck vs Custom CI Matrix
119
+
120
+ | Capability | OpsScript Gate | ShellCheck | Handwritten CI Matrix |
121
+ | :--- | :---: | :---: | :---: |
122
+ | **Runtime execution** | **Yes** | No (Static AST only) | Yes |
123
+ | **Real distro environments** | **Yes (Debian, Ubuntu, Alpine)** | No | Yes |
124
+ | **Preconfigured defaults** | **Yes** | Yes | Requires custom workflow configuration |
125
+ | **Safe container defaults** | **Built-in (`ro`, `cap_drop`, `kill`)** | N/A | User-defined |
126
+ | **Anti-hang stdin protection** | **Built-in (`</dev/null`, noninteractive)** | No | User-defined |
127
+ | **Unified summary & diagnostics** | **Built-in (ASCII + Step Summary)** | Static warnings | User-defined |
128
+
129
+ - **ShellCheck** is indispensable for static analysis (syntax, quoting, SC warnings). OpsScript Gate complements it by testing actual execution behavior in real distributions.
130
+ - **Handwritten CI Matrix** requires maintaining complex Docker configurations, volume mounts, timeout guards, and log parsers across every project. OpsScript Gate packages this into a single check.
131
+
132
+ ---
133
+
134
+ ## Security Boundaries
135
+
136
+ OpsScript Gate uses conservative container defaults when running scripts:
137
+
138
+ 1. **Unprivileged by Design**:
139
+ - Containers run with `privileged=False`.
140
+ - All Linux capabilities are dropped: `cap_drop=["ALL"]`.
141
+ - Privilege escalation is disabled: `security_opt=["no-new-privileges:true"]`.
142
+ 2. **Read-Only Target Mount**:
143
+ - The tested script is mounted read-only (`:ro`) at `/tmp/target_script.sh`.
144
+ - OpsScript Gate does not mount additional host filesystem paths into the test container.
145
+ 3. **Anti-Hang Deadlock Defense**:
146
+ - Disables TTY and stdin (`stdin_open=False`, `tty=False`).
147
+ - Redirects execution: `/bin/sh -c "/bin/sh /tmp/target_script.sh </dev/null"`.
148
+ - Injects `DEBIAN_FRONTEND=noninteractive` and `CI=true`.
149
+ - Any script prompting for user input (`read -p`) fails immediately instead of blocking the CI runner.
150
+ 4. **Timeout & Container Cleanup**:
151
+ - Enforces a configurable timeout (default: 60s). Timed-out containers are sent `SIGKILL` and marked `TIMED_OUT`.
152
+ - Container removal is attempted from a `finally` block during normal Python execution paths, including failures and timeouts.
153
+ 5. **Windows CRLF Defense**:
154
+ - Automatically detects and normalizes carriage returns (`\r\n` -> `\n`) before container execution, preventing false `\r: command not found` errors.
155
+ 6. **POSIX-oriented `/bin/sh` Baseline**:
156
+ - Containers invoke `/bin/sh` directly, catching undeclared Bashism syntax (e.g. bash arrays, `[[ ... ]]`) that break in lightweight Alpine environments.
157
+
158
+ ---
159
+
160
+ ## Default Test Matrix
161
+
162
+ | Image | Distribution | Focus |
163
+ | :--- | :--- | :--- |
164
+ | `debian:12-slim` | Debian 12 (Bookworm) | Minimal glibc + APT base |
165
+ | `ubuntu:22.04` | Ubuntu 22.04 LTS (Jammy) | Enterprise long-term support baseline |
166
+ | `ubuntu:24.04` | Ubuntu 24.04 LTS (Noble) | Modern glibc, updated coreutils & defaults |
167
+ | `alpine:3.20` | Alpine Linux 3.20 | Minimal musl libc + BusyBox (strict POSIX test) |
168
+
169
+ You can customize the matrix at any time via `--matrix` or Action input `matrix`.
170
+
171
+ ---
172
+
173
+ ## CLI Reference
174
+
175
+ ```text
176
+ usage: opsscript-gate run [-h] [--matrix MATRIX] [--timeout TIMEOUT]
177
+ [--format {table,markdown,json}]
178
+ script_path
179
+ ```
180
+
181
+ | Parameter | Type | Default | Description |
182
+ | :--- | :--- | :--- | :--- |
183
+ | `script_path` | Positional | *Required* | Path to target shell script |
184
+ | `--matrix` | String | `debian:12-slim,ubuntu:22.04,ubuntu:24.04,alpine:3.20` | Comma-separated list of Docker images |
185
+ | `--timeout` | Integer | `60` | Hard timeout per container in seconds |
186
+ | `--format` | Choice | `table` | Output format: `table`, `markdown`, or `json` |
187
+ | `--version` | Flag | - | Show version number |
188
+ | `-h, --help` | Flag | - | Show argument help |
189
+
190
+ ### Exit Code Convention
191
+ - **`0`**: All distributions passed (`PASS`).
192
+ - **`1`**: At least one distribution failed (`FAIL`), timed out (`TIMED_OUT`), or errored (`ERROR`).
193
+
194
+ ---
195
+
196
+ ## Examples
197
+
198
+ Check out the [examples/](examples/) directory for self-contained, runnable scenarios:
199
+
200
+ - [`examples/basic/`](examples/basic/): A clean POSIX script that passes across all distributions.
201
+ - [`examples/alpine-incompatibility/`](examples/alpine-incompatibility/): Demonstrates catching implicit Debian/Ubuntu dependencies (e.g. `apt-get`).
202
+ - [`examples/interactive-hang/`](examples/interactive-hang/): Demonstrates how unhandled `read` prompts fail immediately instead of hanging.
203
+ - [`examples/github-actions/`](examples/github-actions/): Ready-to-copy production pull request workflow.
204
+
205
+ ---
206
+
207
+ ## Development & Testing
208
+
209
+ The test suite uses Docker SDK mocking to ensure fast unit tests without needing a local daemon:
210
+
211
+ ```bash
212
+ # Clone and install with test dependencies
213
+ git clone https://github.com/Mresyzz/opsscript-gate.git
214
+ cd opsscript-gate
215
+ pip install -e .[test]
216
+
217
+ # Run unit tests (mocked)
218
+ pytest -v -m "not integration"
219
+
220
+ # Run integration tests (Requires Docker daemon)
221
+ pytest -v
222
+ ```
223
+
224
+ ---
225
+
226
+ ## Roadmap
227
+
228
+ See [ROADMAP.md](ROADMAP.md) for planned capabilities, including:
229
+ - Shebang-aware execution modes (`--shell auto|posix|shebang`)
230
+ - Container resource limits (`--mem-limit`, `--pids-limit`)
231
+ - Configurable network isolation (`--network none|bridge`)
232
+ - Parallel matrix execution
233
+
234
+ ---
235
+
236
+ ## Contributing & Security
237
+
238
+ - **Contributing**: Please review [CONTRIBUTING.md](CONTRIBUTING.md) for pull request guidelines and security boundaries.
239
+ - **Security Policy**: Read [SECURITY.md](SECURITY.md) to report vulnerabilities responsibly.
240
+ - **Changelog**: See [CHANGELOG.md](CHANGELOG.md) for release history.
241
+
242
+ ---
243
+
244
+ ## License
245
+
246
+ OpsScript Gate is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,10 @@
1
+ opsscript_gate/__init__.py,sha256=z8TNBn2dtWcnTqic6vze5QJulzkp0cRD7LM0ZUmiVf0,143
2
+ opsscript_gate/cli.py,sha256=T5pUn38uHrL3aGgM15mrgh9c7reJfJHwFdk1KzpYHTk,3617
3
+ opsscript_gate/models.py,sha256=uy_KwPYq9yOhgZmgn6XQeNKQ8dJ8JTfW7wuXh04S2vk,1537
4
+ opsscript_gate/reporter.py,sha256=CtoGd21Ue4BLkZS4caIL3_zzFHA1jJc9sqo_eiwkFbM,5292
5
+ opsscript_gate/runner.py,sha256=kteqsVJTSBMoM5aj6moP-Ych-H6-8VdH3RZbpYk3RVo,9223
6
+ opsscript_gate-0.1.2.dist-info/METADATA,sha256=IzPh5eLTxRFxvZMyeMMUsN5mjeyJRAH49uwQfEOHXLE,10774
7
+ opsscript_gate-0.1.2.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
8
+ opsscript_gate-0.1.2.dist-info/entry_points.txt,sha256=rJgpiXse5cCM3KCRbcVOhInoVLxpLfP3XKn0x79yMr4,59
9
+ opsscript_gate-0.1.2.dist-info/licenses/LICENSE,sha256=4Xo4yBr2jDq8iOccGx2to60KI5fjGNxbIKoes6AoSmo,1084
10
+ opsscript_gate-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ opsscript-gate = opsscript_gate.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpsScript Gate Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.