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.
- opsscript_gate/__init__.py +4 -0
- opsscript_gate/cli.py +129 -0
- opsscript_gate/models.py +55 -0
- opsscript_gate/reporter.py +158 -0
- opsscript_gate/runner.py +292 -0
- opsscript_gate-0.1.2.dist-info/METADATA +246 -0
- opsscript_gate-0.1.2.dist-info/RECORD +10 -0
- opsscript_gate-0.1.2.dist-info/WHEEL +4 -0
- opsscript_gate-0.1.2.dist-info/entry_points.txt +2 -0
- opsscript_gate-0.1.2.dist-info/licenses/LICENSE +21 -0
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())
|
opsscript_gate/models.py
ADDED
|
@@ -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
|
opsscript_gate/runner.py
ADDED
|
@@ -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
|
+
[](https://github.com/Mresyzz/opsscript-gate/actions/workflows/test.yml)
|
|
34
|
+
[](https://github.com/Mresyzz/opsscript-gate/actions/workflows/demo.yml)
|
|
35
|
+
[](https://github.com/marketplace/actions/opsscript-gate)
|
|
36
|
+
[](https://github.com/Mresyzz/opsscript-gate/releases)
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
|
+
[](https://opensource.org/licenses/MIT)
|
|
39
|
+
[](#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,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.
|