94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
377 lines
12 KiB
Python
Executable File
377 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Analyze srtslurm logs with opencode inside a Modal sandbox.
|
||
|
||
This script accepts either:
|
||
- a local log directory
|
||
- a `.tar.gz` bundle such as `multinode_server_logs.tar.gz`
|
||
|
||
It uploads the logs into an ephemeral Modal sandbox, installs and runs
|
||
opencode with an analysis prompt, and prints the resulting markdown.
|
||
|
||
Example:
|
||
uv run --with modal python scripts/ci/slurm/analyze_logs_with_modal.py \
|
||
--tarball /tmp/multinode_server_logs.tar.gz \
|
||
--job-id 4645
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import logging
|
||
import os
|
||
import re
|
||
import shlex
|
||
import shutil
|
||
import tarfile
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
try:
|
||
import modal
|
||
except ImportError: # pragma: no cover - runtime guard for local usage
|
||
modal = None
|
||
|
||
|
||
logger = logging.getLogger("slurm_log_analysis")
|
||
|
||
SANDBOX_TIMEOUT = 600
|
||
DEFAULT_MODAL_SECRET_NAME = "or"
|
||
DEFAULT_MODEL = "openrouter/minimax/minimax-m2.7"
|
||
DEFAULT_REPOS = [
|
||
"https://github.com/sgl-project/sglang.git",
|
||
]
|
||
PROMPT_PATH = Path(__file__).with_name("log_analysis_prompt.md")
|
||
|
||
# Matches common API key / token patterns (sk-..., ak-..., as-..., key-..., etc.)
|
||
_SECRET_PATTERN = re.compile(
|
||
r"""(?:"""
|
||
r"""(?:sk|ak|as|key|token|secret|bearer)[-_][A-Za-z0-9_\-]{16,}"""
|
||
r"""|"""
|
||
r"""(?:OPENROUTER_API_KEY|MODAL_TOKEN_ID|MODAL_TOKEN_SECRET|ANTHROPIC_API_KEY)"""
|
||
r"""[=:]\s*\S+"""
|
||
r""")""",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def sanitize(text: str) -> str:
|
||
"""Redact strings that look like API keys or secrets."""
|
||
if not text:
|
||
return text
|
||
sanitized = _SECRET_PATTERN.sub("[REDACTED]", text)
|
||
# Also redact any env var values we know are secrets
|
||
for var in ("OPENROUTER_API_KEY", "MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"):
|
||
val = os.environ.get(var)
|
||
if val and len(val) > 8:
|
||
sanitized = sanitized.replace(val, "[REDACTED]")
|
||
return sanitized
|
||
|
||
|
||
def configure_logging(verbose: bool) -> None:
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(levelname)s: %(message)s",
|
||
)
|
||
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
||
|
||
|
||
def extract_tarball(tarball: Path, destination: Path) -> None:
|
||
with tarfile.open(tarball, "r:gz") as archive:
|
||
# Python 3.14 changes the default extraction behavior. Use the
|
||
# data filter when available so extraction remains explicit.
|
||
if "data" in tarfile._NAMED_FILTERS: # type: ignore[attr-defined]
|
||
archive.extractall(destination, filter="data")
|
||
else:
|
||
archive.extractall(destination)
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(
|
||
description="Analyze a srtslurm log bundle with opencode in Modal."
|
||
)
|
||
source = parser.add_mutually_exclusive_group(required=True)
|
||
source.add_argument(
|
||
"--tarball",
|
||
type=Path,
|
||
help="Path to a local multinode_server_logs.tar.gz bundle.",
|
||
)
|
||
source.add_argument(
|
||
"--log-dir",
|
||
type=Path,
|
||
help="Path to an unpacked log directory.",
|
||
)
|
||
parser.add_argument(
|
||
"--job-id",
|
||
default="unknown",
|
||
help="Job identifier used in the report header and logs.",
|
||
)
|
||
parser.add_argument(
|
||
"--model",
|
||
default=DEFAULT_MODEL,
|
||
help="Model selector to pass to opencode run.",
|
||
)
|
||
parser.add_argument(
|
||
"--output",
|
||
type=Path,
|
||
help="Optional path to write the markdown analysis.",
|
||
)
|
||
parser.add_argument(
|
||
"--repo-url",
|
||
action="append",
|
||
dest="repo_urls",
|
||
help=(
|
||
"Optional extra repo URL to clone into the sandbox for context. "
|
||
"Can be specified multiple times."
|
||
),
|
||
)
|
||
parser.add_argument(
|
||
"--timeout-seconds",
|
||
type=int,
|
||
default=SANDBOX_TIMEOUT,
|
||
help="Sandbox lifetime in seconds.",
|
||
)
|
||
parser.add_argument(
|
||
"--modal-secret-name",
|
||
default=DEFAULT_MODAL_SECRET_NAME,
|
||
help="Modal secret name that provides OPENROUTER_API_KEY to the sandbox.",
|
||
)
|
||
parser.add_argument(
|
||
"--verbose",
|
||
action="store_true",
|
||
help="Enable debug logging.",
|
||
)
|
||
return parser.parse_args()
|
||
|
||
|
||
def build_sandbox_image() -> modal.Image:
|
||
if modal is None:
|
||
raise RuntimeError(
|
||
"The 'modal' package is required. Run this script with "
|
||
"`uv run --with modal python ...` or install modal locally."
|
||
)
|
||
|
||
return (
|
||
modal.Image.debian_slim(python_version="3.12")
|
||
.apt_install("bash", "curl", "git", "gh")
|
||
.run_commands(
|
||
"curl -fsSL https://opencode.ai/install | bash",
|
||
)
|
||
.env(
|
||
{
|
||
"PATH": "/root/.opencode/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||
}
|
||
)
|
||
)
|
||
|
||
|
||
def prepare_log_dir(args: argparse.Namespace) -> tuple[Path, Path | None]:
|
||
if args.log_dir:
|
||
if not args.log_dir.is_dir():
|
||
raise FileNotFoundError(f"log directory not found: {args.log_dir}")
|
||
return args.log_dir.resolve(), None
|
||
|
||
assert args.tarball is not None
|
||
if not args.tarball.is_file():
|
||
raise FileNotFoundError(f"tarball not found: {args.tarball}")
|
||
|
||
temp_dir = Path(tempfile.mkdtemp(prefix="sglang_logs_"))
|
||
extract_tarball(args.tarball, temp_dir)
|
||
return temp_dir.resolve(), temp_dir
|
||
|
||
|
||
def build_prompt(job_id: str, repo_urls: list[str]) -> str:
|
||
skill_content = PROMPT_PATH.read_text()
|
||
repo_lines = []
|
||
for repo_url in repo_urls:
|
||
repo_name = repo_url.rsplit("/", 1)[-1].removesuffix(".git")
|
||
repo_lines.append(f"- **{repo_name} repo**: `/workspace/repos/{repo_name}/`")
|
||
repo_section = (
|
||
"\n".join(repo_lines) if repo_lines else "- No extra repos were requested."
|
||
)
|
||
|
||
return f"""{skill_content}
|
||
|
||
---
|
||
|
||
## Your Environment
|
||
|
||
- **Logs**: `/workspace/logs/`
|
||
- **GitHub CLI**: `gh` is installed and authenticated.
|
||
{repo_section}
|
||
|
||
## Job
|
||
|
||
You are analyzing job `{job_id}`. Follow Steps 1–5 in the prompt above.
|
||
|
||
**You MUST write the final markdown report to `/workspace/logs/ai_analysis.md`.**
|
||
This is a hard requirement. Do not just print the report to stdout. Use your
|
||
file-writing tool to create `/workspace/logs/ai_analysis.md` with the full
|
||
analysis. The downstream pipeline reads this file.
|
||
|
||
**You MUST file GitHub issues when the root cause is clear (Category A or B).**
|
||
Do not skip issue filing. The whole point of this system is automated triage.
|
||
"""
|
||
|
||
|
||
def upload_tree(sandbox: modal.Sandbox, log_dir: Path) -> None:
|
||
log_files = [path for path in log_dir.rglob("*") if path.is_file()]
|
||
logger.info("Uploading %d log files into the sandbox", len(log_files))
|
||
for index, log_file in enumerate(log_files, start=1):
|
||
rel_path = log_file.relative_to(log_dir)
|
||
remote_path = str(Path("/workspace/logs") / rel_path)
|
||
sandbox.mkdir(str(Path(remote_path).parent), parents=True)
|
||
sandbox.filesystem.write_bytes(log_file.read_bytes(), remote_path)
|
||
if index % 10 == 0 or index == len(log_files):
|
||
logger.info("Uploaded %d/%d files", index, len(log_files))
|
||
|
||
|
||
def clone_context_repos(sandbox: modal.Sandbox, repo_urls: list[str]) -> None:
|
||
if not repo_urls:
|
||
return
|
||
|
||
sandbox.mkdir("/workspace/repos", parents=True)
|
||
|
||
for repo_url in repo_urls:
|
||
repo_name = repo_url.rsplit("/", 1)[-1].removesuffix(".git")
|
||
logger.info("Cloning %s into the sandbox", repo_name)
|
||
sandbox.exec(
|
||
"git",
|
||
"clone",
|
||
"--depth",
|
||
"100",
|
||
repo_url,
|
||
f"/workspace/repos/{repo_name}",
|
||
).wait()
|
||
|
||
|
||
def read_optional_file(sandbox: modal.Sandbox, path: str) -> str | None:
|
||
try:
|
||
return sandbox.filesystem.read_text(path)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def run_opencode_analysis(
|
||
*,
|
||
log_dir: Path,
|
||
job_id: str,
|
||
model: str,
|
||
repo_urls: list[str],
|
||
timeout_seconds: int,
|
||
modal_secret_name: str,
|
||
) -> str:
|
||
prompt = build_prompt(job_id, repo_urls)
|
||
app = modal.App.lookup("sglang-log-analyzer", create_if_missing=True)
|
||
sandbox = modal.Sandbox.create(
|
||
app=app,
|
||
image=build_sandbox_image(),
|
||
timeout=timeout_seconds,
|
||
secrets=[modal.Secret.from_name(modal_secret_name)],
|
||
)
|
||
logger.info("Created Modal sandbox %s", sandbox.object_id)
|
||
|
||
try:
|
||
sandbox.mkdir("/workspace/logs", parents=True)
|
||
sandbox.mkdir("/workspace/repos", parents=True)
|
||
|
||
clone_context_repos(sandbox, repo_urls)
|
||
upload_tree(sandbox, log_dir)
|
||
|
||
sandbox.filesystem.write_text(prompt, "/workspace/prompt.txt")
|
||
|
||
runner_script = f"""#!/bin/bash
|
||
set -uo pipefail
|
||
cd /workspace
|
||
if opencode run \\
|
||
--dangerously-skip-permissions \\
|
||
--dir /workspace/logs \\
|
||
-m {shlex.quote(model)} \\
|
||
"$(cat /workspace/prompt.txt)" \\
|
||
< /dev/null \\
|
||
> /workspace/logs/opencode.stdout \\
|
||
2> /workspace/logs/opencode.stderr; then
|
||
echo 0 > /workspace/logs/opencode.exitcode
|
||
else
|
||
echo $? > /workspace/logs/opencode.exitcode
|
||
fi
|
||
ls -la /workspace/logs > /workspace/logs/log_dir_listing.txt
|
||
"""
|
||
sandbox.filesystem.write_text(runner_script, "/workspace/run_opencode.sh")
|
||
sandbox.exec("chmod", "+x", "/workspace/run_opencode.sh").wait()
|
||
|
||
logger.info("Running opencode analysis")
|
||
process = sandbox.exec(
|
||
"bash",
|
||
"/workspace/run_opencode.sh",
|
||
)
|
||
process.wait()
|
||
|
||
stderr = process.stderr.read()
|
||
if stderr:
|
||
logger.warning("runner stderr: %s", sanitize(stderr[:500]))
|
||
|
||
exitcode = read_optional_file(sandbox, "/workspace/logs/opencode.exitcode")
|
||
opencode_stdout = (
|
||
read_optional_file(sandbox, "/workspace/logs/opencode.stdout") or ""
|
||
)
|
||
opencode_stderr = read_optional_file(sandbox, "/workspace/logs/opencode.stderr")
|
||
log_dir_listing = read_optional_file(
|
||
sandbox, "/workspace/logs/log_dir_listing.txt"
|
||
)
|
||
try:
|
||
ai_analysis = read_optional_file(sandbox, "/workspace/logs/ai_analysis.md")
|
||
if ai_analysis and ai_analysis.strip():
|
||
return sanitize(ai_analysis)
|
||
|
||
if opencode_stdout.strip():
|
||
return sanitize(opencode_stdout)
|
||
|
||
raise RuntimeError("opencode completed without producing analysis output")
|
||
except Exception as exc:
|
||
stdout = process.stdout.read()
|
||
if stdout:
|
||
return sanitize(stdout)
|
||
details = [
|
||
f"opencode analysis did not produce a usable report: {exc}",
|
||
f"exitcode={exitcode!r}",
|
||
f"stdout_preview={sanitize(opencode_stdout[:500])!r}",
|
||
f"stderr_preview={sanitize((opencode_stderr or '')[:500])!r}",
|
||
f"log_dir_listing={sanitize((log_dir_listing or '')[:500])!r}",
|
||
]
|
||
raise RuntimeError(" ".join(details)) from exc
|
||
finally:
|
||
sandbox.terminate()
|
||
|
||
|
||
def main() -> int:
|
||
args = parse_args()
|
||
configure_logging(args.verbose)
|
||
|
||
repo_urls = list(DEFAULT_REPOS)
|
||
if args.repo_urls:
|
||
repo_urls.extend(args.repo_urls)
|
||
|
||
log_dir, cleanup_dir = prepare_log_dir(args)
|
||
try:
|
||
analysis = run_opencode_analysis(
|
||
log_dir=log_dir,
|
||
job_id=args.job_id,
|
||
model=args.model,
|
||
repo_urls=repo_urls,
|
||
timeout_seconds=args.timeout_seconds,
|
||
modal_secret_name=args.modal_secret_name,
|
||
)
|
||
finally:
|
||
if cleanup_dir is not None:
|
||
shutil.rmtree(cleanup_dir, ignore_errors=True)
|
||
|
||
print(analysis)
|
||
if args.output:
|
||
args.output.write_text(analysis)
|
||
logger.info("Wrote analysis to %s", args.output)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|