#!/usr/bin/env bash set -euo pipefail TOOL_VERSION="1" REDACTION_VERSION="1" DEFAULT_LOG_TAIL=200 MAX_LOG_CONTAINERS=25 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" OUTPUT_DIR="${ROOT_DIR}/artifacts/support" JSON_OUTPUT=false INCLUDE_LOGS=true DOCKER_BIN="${ODS_SUPPORT_BUNDLE_DOCKER:-docker}" usage() { cat <<'EOF' Usage: scripts/ods-support-bundle.sh [OPTIONS] Create a redacted diagnostics bundle for ODS support. Options: --output DIR Write bundle directory/archive under DIR --json Print machine-readable result JSON --no-logs Skip Docker container log collection -h, --help Show this help The generated archive is safe-by-default, but review it before posting to a public issue. Raw .env is never included; only config/env.redacted is written. EOF } while [[ $# -gt 0 ]]; do case "$1" in --output) OUTPUT_DIR="${2:-}" [[ -n "$OUTPUT_DIR" ]] || { echo "ERROR: --output requires a directory" >&2; exit 2; } shift 2 ;; --json) JSON_OUTPUT=true shift ;; --no-logs) INCLUDE_LOGS=false shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown argument: $1" >&2 usage >&2 exit 2 ;; esac done detect_python() { if command -v python3 >/dev/null 2>&1; then command -v python3 elif command -v python >/dev/null 2>&1; then command -v python else return 1 fi } detect_bash() { local candidate for candidate in \ "${ODS_SUPPORT_BUNDLE_BASH:-}" \ /opt/homebrew/bin/bash \ /usr/local/bin/bash \ "$(command -v bash 2>/dev/null || true)" \ /bin/bash do [[ -n "$candidate" && -x "$candidate" ]] || continue if "$candidate" -c '[[ ${BASH_VERSINFO[0]:-0} -ge 4 ]]' >/dev/null 2>&1; then printf '%s\n' "$candidate" return 0 fi done command -v bash 2>/dev/null || printf '%s\n' /bin/bash } PYTHON_CMD="$(detect_python)" || { echo "ERROR: python3 or python is required to build a redacted support bundle" >&2 exit 1 } BASH_CMD="$(detect_bash)" mkdir -p "$OUTPUT_DIR" OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" timestamp="$(date -u +%Y%m%dT%H%M%SZ)" BUNDLE_NAME="ods-support-${timestamp}-$$" BUNDLE_DIR="${OUTPUT_DIR%/}/${BUNDLE_NAME}" ARCHIVE_PATH="${BUNDLE_DIR}.tar.gz" STATUS_FILE="${BUNDLE_DIR}/manifest/command-status.tsv" mkdir -p \ "$BUNDLE_DIR/config" \ "$BUNDLE_DIR/diagnostics" \ "$BUNDLE_DIR/docker" \ "$BUNDLE_DIR/logs" \ "$BUNDLE_DIR/manifest" \ "$BUNDLE_DIR/system" \ "$BUNDLE_DIR/validation" shell_quote() { printf "%q" "$1" } record_command() { local label="$1" local rel_path="$2" local exit_code="$3" printf '%s\t%s\t%s\n' "$label" "$rel_path" "$exit_code" >> "$STATUS_FILE" } redact_file() { local file="$1" [[ -f "$file" ]] || return 0 "$PYTHON_CMD" - "$file" <<'PY' import re import sys from pathlib import Path path = Path(sys.argv[1]) try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: raise SystemExit(0) secret_word = r"(?:KEY|TOKEN|SECRET|PASSWORD|PASS|SALT|AUTH|CREDENTIAL)" patterns = [ (re.compile(r"(?i)(Bearer\s+)[A-Za-z0-9._~+/=-]+"), r"\1[REDACTED]"), (re.compile(r"(?i)((?:authorization|x-api-key|api-key|apikey)\s*[:=]\s*)([\"']?)[^\"'\s,}]+"), r"\1\2[REDACTED]"), (re.compile(r"(?i)(https?://)([^/\s:@]+):([^@\s/]+)@"), r"\1[REDACTED]@"), ( re.compile(rf"(?im)^([ \t]*(?:export[ \t]+)?[A-Za-z_][A-Za-z0-9_]*{secret_word}[A-Za-z0-9_]*[ \t]*=[ \t]*).*$"), r"\1[REDACTED]", ), ( re.compile(rf"(?im)(^|[{{,]\s*)([\"']?[A-Za-z0-9_-]*{secret_word}[A-Za-z0-9_-]*[\"']?\s*:\s*)([\"']?)[^\"'\s,\n}}{{\[]+([\"']?)"), r'\1\2"[REDACTED]"', ), ] for pattern, replacement in patterns: text = pattern.sub(replacement, text) path.write_text(text, encoding="utf-8") PY } collect_shell() { local rel_path="$1" local label="$2" local command="$3" local abs_path="${BUNDLE_DIR}/${rel_path}" local exit_code mkdir -p "$(dirname "$abs_path")" set +e ( cd "$ROOT_DIR" || exit 1 "$BASH_CMD" -lc "$command" ) > "$abs_path" 2>&1 exit_code=$? set -e redact_file "$abs_path" record_command "$label" "$rel_path" "$exit_code" return 0 } write_file() { local rel_path="$1" local abs_path="${BUNDLE_DIR}/${rel_path}" mkdir -p "$(dirname "$abs_path")" cat > "$abs_path" redact_file "$abs_path" } copy_if_exists() { local src="$1" local rel_path="$2" local abs_path="${BUNDLE_DIR}/${rel_path}" if [[ -f "$ROOT_DIR/$src" ]]; then mkdir -p "$(dirname "$abs_path")" cp "$ROOT_DIR/$src" "$abs_path" redact_file "$abs_path" fi } write_redacted_env() { local env_path="$ROOT_DIR/.env" local out_path="$BUNDLE_DIR/config/env.redacted" if [[ ! -f "$env_path" ]]; then printf 'No .env file found at %s\n' "$env_path" > "$out_path" return 0 fi "$PYTHON_CMD" - "$env_path" "$out_path" <<'PY' import re import sys from pathlib import Path src = Path(sys.argv[1]) dest = Path(sys.argv[2]) secret = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|PASS|SALT|AUTH|CREDENTIAL)", re.I) lines = [] for line in src.read_text(encoding="utf-8", errors="replace").splitlines(): stripped = line.strip() if not stripped or stripped.startswith("#") or "=" not in line: lines.append(line) continue prefix, _value = line.split("=", 1) key = prefix.strip() if key.startswith("export "): key = key[7:].strip() if secret.search(key): lines.append(f"{prefix}=[REDACTED]") else: lines.append(line) dest.write_text("\n".join(lines) + "\n", encoding="utf-8") PY redact_file "$out_path" } read_env_value() { local key="$1" local default="$2" local env_path="$ROOT_DIR/.env" if [[ ! -f "$env_path" ]]; then printf '%s\n' "$default" return 0 fi "$PYTHON_CMD" - "$env_path" "$key" "$default" <<'PY' import sys from pathlib import Path path = Path(sys.argv[1]) target = sys.argv[2] default = sys.argv[3] for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): stripped = line.strip() if not stripped or stripped.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() if key.startswith("export "): key = key[7:].strip() if key == target: print(value.strip().strip('"').strip("'")) break else: print(default) PY } docker_cli_available() { [[ "${ODS_SUPPORT_BUNDLE_DISABLE_DOCKER:-}" == "1" ]] && return 1 command -v "$DOCKER_BIN" >/dev/null 2>&1 } docker_daemon_available() { docker_cli_available || return 1 if command -v timeout >/dev/null 2>&1; then timeout 10 "$DOCKER_BIN" info >/dev/null 2>&1 else "$DOCKER_BIN" info >/dev/null 2>&1 fi } docker_compose_available() { docker_cli_available && "$DOCKER_BIN" compose version >/dev/null 2>&1 } safe_filename() { "$PYTHON_CMD" - "$1" <<'PY' import re import sys name = re.sub(r"[^A-Za-z0-9_.-]+", "_", sys.argv[1]).strip("._") print(name or "container") PY } collect_system_info() { write_file "system/bash.txt" </dev/null || printf 'unknown\n') EOF collect_shell "system/platform.txt" "platform-summary" ' printf "generated_at_utc="; date -u +"%Y-%m-%dT%H:%M:%SZ" printf "root_dir=%s\n" "$PWD" uname -a 2>/dev/null || true if [[ -r /proc/sys/kernel/osrelease ]] && grep -qi microsoft /proc/sys/kernel/osrelease; then echo "wsl=true" elif [[ -r /proc/version ]] && grep -qi microsoft /proc/version; then echo "wsl=true" else echo "wsl=false" fi if [[ -f /etc/os-release ]]; then echo "" cat /etc/os-release fi ' collect_shell "system/resources.txt" "resource-summary" ' echo "Disk:" df -h . "$HOME" 2>/dev/null || df -h . 2>/dev/null || true echo "" echo "Memory:" if command -v free >/dev/null 2>&1; then free -h elif [[ -f /proc/meminfo ]]; then head -20 /proc/meminfo else vm_stat 2>/dev/null || true fi ' collect_shell "system/listening-ports.txt" "listening-ports" ' if command -v ss >/dev/null 2>&1; then ss -ltnp 2>/dev/null || ss -ltn 2>/dev/null || true elif command -v lsof >/dev/null 2>&1; then lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null || true elif command -v netstat >/dev/null 2>&1; then netstat -an 2>/dev/null | grep LISTEN || true else echo "No supported port listing tool found" fi ' collect_shell "system/git.txt" "git-summary" ' git rev-parse --show-toplevel 2>/dev/null || true git status --short --branch 2>/dev/null || true git log --oneline --decorate -5 2>/dev/null || true git remote -v 2>/dev/null || true ' } collect_config() { copy_if_exists "manifest.json" "config/manifest.json" copy_if_exists ".env.schema.json" "config/env.schema.json" copy_if_exists ".env.example" "config/env.example" write_redacted_env } collect_diagnostics() { if [[ -f "$ROOT_DIR/scripts/ods-doctor.sh" ]]; then collect_shell "diagnostics/ods-doctor.log" "ods-doctor" "$(shell_quote "$BASH_CMD") $(shell_quote "$ROOT_DIR/scripts/ods-doctor.sh") $(shell_quote "$BUNDLE_DIR/diagnostics/ods-doctor.json")" redact_file "$BUNDLE_DIR/diagnostics/ods-doctor.json" else write_file "diagnostics/ods-doctor.log" <<< "scripts/ods-doctor.sh not found" record_command "ods-doctor" "diagnostics/ods-doctor.log" "127" fi if [[ -f "$ROOT_DIR/scripts/audit-extensions.py" ]]; then collect_shell "diagnostics/extension-audit.json" "extension-audit" "$(shell_quote "$PYTHON_CMD") $(shell_quote "$ROOT_DIR/scripts/audit-extensions.py") --project-dir $(shell_quote "$ROOT_DIR") --json" else write_file "diagnostics/extension-audit.json" <<< '{"error":"scripts/audit-extensions.py not found"}' record_command "extension-audit" "diagnostics/extension-audit.json" "127" fi } collect_compose_validation() { local gpu_backend local tier local gpu_count local ods_mode local lemonade_external local amd_runtime local amd_managed local flags_file="$BUNDLE_DIR/validation/compose-flags.txt" local flags_err="$BUNDLE_DIR/validation/compose-flags.err" local flags local resolve_exit gpu_backend="$(read_env_value GPU_BACKEND nvidia)" tier="$(read_env_value TIER 1)" gpu_count="$(read_env_value GPU_COUNT 1)" ods_mode="$(read_env_value ODS_MODE local)" lemonade_external="$(read_env_value LEMONADE_EXTERNAL false)" amd_runtime="$(read_env_value AMD_INFERENCE_RUNTIME "")" amd_managed="$(read_env_value AMD_INFERENCE_MANAGED "")" if [[ ! -f "$ROOT_DIR/scripts/resolve-compose-stack.sh" ]]; then write_file "validation/compose-config.txt" <<< "scripts/resolve-compose-stack.sh not found" record_command "resolve-compose-stack" "validation/compose-config.txt" "127" return 0 fi set +e flags="$( cd "$ROOT_DIR" && \ LEMONADE_EXTERNAL="$lemonade_external" \ AMD_INFERENCE_RUNTIME="$amd_runtime" \ AMD_INFERENCE_MANAGED="$amd_managed" \ "$BASH_CMD" scripts/resolve-compose-stack.sh \ --script-dir "$ROOT_DIR" \ --tier "$tier" \ --gpu-backend "$gpu_backend" \ --gpu-count "$gpu_count" \ --ods-mode "$ods_mode" \ --skip-broken \ 2> "$flags_err" )" resolve_exit=$? set -e { printf 'GPU_BACKEND=%s\n' "$gpu_backend" printf 'TIER=%s\n' "$tier" printf 'GPU_COUNT=%s\n' "$gpu_count" printf 'ODS_MODE=%s\n' "$ods_mode" printf 'LEMONADE_EXTERNAL=%s\n' "$lemonade_external" printf 'AMD_INFERENCE_RUNTIME=%s\n' "$amd_runtime" printf 'AMD_INFERENCE_MANAGED=%s\n' "$amd_managed" printf 'COMPOSE_FLAGS=%s\n' "$flags" if [[ -s "$flags_err" ]]; then echo "" echo "stderr:" cat "$flags_err" fi } > "$flags_file" redact_file "$flags_file" redact_file "$flags_err" record_command "resolve-compose-stack" "validation/compose-flags.txt" "$resolve_exit" if [[ "$resolve_exit" -ne 0 ]]; then write_file "validation/compose-config.txt" <<< "Compose validation skipped because compose flag resolution failed" record_command "compose-config" "validation/compose-config.txt" "127" return 0 fi if [[ "${ODS_SUPPORT_BUNDLE_DISABLE_DOCKER:-}" == "1" ]] || { ! docker_compose_available && ! command -v docker-compose >/dev/null 2>&1; }; then write_file "validation/compose-config.txt" <<< "Compose validation skipped because docker compose is not available" record_command "compose-config" "validation/compose-config.txt" "127" return 0 fi local cmd cmd="$(shell_quote "$BASH_CMD") $(shell_quote "$ROOT_DIR/scripts/validate-compose-stack.sh") --compose-flags $(shell_quote "$flags")" if [[ -f "$ROOT_DIR/.env" ]]; then cmd="${cmd} --env-file $(shell_quote "$ROOT_DIR/.env")" fi collect_shell "validation/compose-config.txt" "compose-config" "$cmd" } collect_docker() { if ! docker_cli_available; then write_file "docker/unavailable.txt" <<< "Docker CLI not available or disabled" record_command "docker-version" "docker/unavailable.txt" "127" return 0 fi collect_shell "docker/version.txt" "docker-version" "$(shell_quote "$DOCKER_BIN") version" if ! docker_daemon_available; then write_file "docker/info.txt" <<< "Docker daemon is not reachable" record_command "docker-info" "docker/info.txt" "1" return 0 fi collect_shell "docker/info.txt" "docker-info" "$(shell_quote "$DOCKER_BIN") info" collect_shell "docker/ps.txt" "docker-ps" "$(shell_quote "$DOCKER_BIN") ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'" if [[ "$INCLUDE_LOGS" != "true" ]]; then write_file "logs/skipped.txt" <<< "Docker log collection skipped by --no-logs" return 0 fi local names_file="$BUNDLE_DIR/docker/container-names.txt" local names_exit set +e "$DOCKER_BIN" ps --format '{{.Names}}' > "$names_file" 2>&1 names_exit=$? set -e redact_file "$names_file" record_command "docker-container-names" "docker/container-names.txt" "$names_exit" [[ "$names_exit" -eq 0 ]] || return 0 local count=0 local container while IFS= read -r container; do [[ -n "$container" ]] || continue case "$container" in ods-*|*ods*) ;; *) continue ;; esac count=$((count + 1)) [[ "$count" -le "$MAX_LOG_CONTAINERS" ]] || break local safe safe="$(safe_filename "$container")" collect_shell "logs/${safe}.log" "docker-logs:${container}" "$(shell_quote "$DOCKER_BIN") logs --tail ${DEFAULT_LOG_TAIL} $(shell_quote "$container")" done < "$names_file" if [[ "$count" -eq 0 ]]; then write_file "logs/no-ods-containers.txt" <<< "No running ODS-like containers found" fi } write_manifest() { "$PYTHON_CMD" - "$BUNDLE_DIR" "$ARCHIVE_PATH" "$TOOL_VERSION" "$REDACTION_VERSION" "$INCLUDE_LOGS" <<'PY' import json import os import sys from datetime import datetime, timezone from pathlib import Path bundle_dir = Path(sys.argv[1]) archive_path = sys.argv[2] tool_version = sys.argv[3] redaction_version = sys.argv[4] include_logs = sys.argv[5].lower() == "true" status_path = bundle_dir / "manifest" / "command-status.tsv" commands = [] if status_path.exists(): for line in status_path.read_text(encoding="utf-8", errors="replace").splitlines(): parts = line.split("\t") if len(parts) != 3: continue label, path, exit_code = parts try: exit_code_value = int(exit_code) except ValueError: exit_code_value = None commands.append({"label": label, "path": path, "exit_code": exit_code_value}) files = [] for path in sorted(bundle_dir.rglob("*")): if not path.is_file(): continue rel = path.relative_to(bundle_dir).as_posix() if rel == "manifest.json": continue try: size = path.stat().st_size except OSError: size = None files.append({"path": rel, "size_bytes": size}) manifest = { "tool": "ods-support-bundle", "tool_version": tool_version, "redaction_version": redaction_version, "generated_at": datetime.now(timezone.utc).isoformat(), "archive_path": archive_path, "logs_included": include_logs, "files": files, "commands": commands, } (bundle_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") PY } write_evidence() { "$PYTHON_CMD" - "$BUNDLE_DIR" "$ROOT_DIR" <<'PY' import hashlib import json import platform import re import shlex import sys from datetime import datetime, timezone from pathlib import Path bundle_dir = Path(sys.argv[1]) root_dir = Path(sys.argv[2]) status_path = bundle_dir / "manifest" / "command-status.tsv" secret = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|PASS|SALT|AUTH|CREDENTIAL)", re.I) def load_json(path): if not path.exists(): return None try: return json.loads(path.read_text(encoding="utf-8", errors="replace")) except Exception: return None def env_pairs(path): result = {} if not path.exists(): return result for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): stripped = line.strip() if not stripped or stripped.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() if key.startswith("export "): key = key[7:].strip() result[key] = value.strip().strip('"').strip("'") return result def sha256_file(path): if not path.exists() or not path.is_file(): return None h = hashlib.sha256() with path.open("rb") as fh: for chunk in iter(lambda: fh.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def command_statuses(): commands = [] if status_path.exists(): for line in status_path.read_text(encoding="utf-8", errors="replace").splitlines(): parts = line.split("\t") if len(parts) != 3: continue label, path, exit_code = parts try: exit_code_value = int(exit_code) except ValueError: exit_code_value = None commands.append({"label": label, "path": path, "exit_code": exit_code_value}) return commands def is_wsl(): for candidate in (Path("/proc/sys/kernel/osrelease"), Path("/proc/version")): try: if "microsoft" in candidate.read_text(encoding="utf-8", errors="replace").lower(): return True except OSError: continue return False def parse_compose_flags(): path = bundle_dir / "validation" / "compose-flags.txt" result = {"raw": "", "files": []} if not path.exists(): return result text = path.read_text(encoding="utf-8", errors="replace") for line in text.splitlines(): if line.startswith("COMPOSE_FLAGS="): raw = line.split("=", 1)[1].strip() result["raw"] = raw try: parts = shlex.split(raw) except ValueError: parts = raw.split() files = [] idx = 0 while idx < len(parts): part = parts[idx] if part in {"-f", "--file"} and idx + 1 < len(parts): files.append(parts[idx + 1]) idx += 2 continue if part.startswith("--file="): files.append(part.split("=", 1)[1]) idx += 1 result["files"] = files break return result manifest = load_json(root_dir / "manifest.json") or {} doctor = load_json(bundle_dir / "diagnostics" / "ods-doctor.json") or {} extension_audit = load_json(bundle_dir / "diagnostics" / "extension-audit.json") or {} env = env_pairs(root_dir / ".env") public_env_keys = { key: { "present": True, "redacted": bool(secret.search(key)), "value": None if secret.search(key) else value, } for key, value in sorted(env.items()) } config_hash_targets = [ "manifest.json", ".env.schema.json", "config/ports.json", "config/golden-paths.json", "config/generated-config-contracts.json", "config/litellm/lemonade.yaml", "extensions/services/hermes/cli-config.yaml.template", ] evidence = { "version": "1", "generated_at": datetime.now(timezone.utc).isoformat(), "tool": "ods-support-bundle", "platform": { "system": platform.system(), "release": platform.release(), "machine": platform.machine(), "python": platform.python_version(), "wsl": is_wsl(), }, "ods": { "version": manifest.get("ods_version") or manifest.get("release", {}).get("version"), "release": manifest.get("release", {}), }, "backend": { "ods_mode": env.get("ODS_MODE"), "gpu_backend": env.get("GPU_BACKEND"), "gpu_count": env.get("GPU_COUNT"), "llm_backend": env.get("LLM_BACKEND"), "llm_model": env.get("LLM_MODEL"), }, "inference_contract": doctor.get("runtime", {}).get("inference_contract", {}), "env_keys": public_env_keys, "compose": parse_compose_flags(), "doctor_summary": doctor.get("summary", {}), "extension_audit_summary": extension_audit.get("summary", {}), "config_hashes": { target: sha256_file(root_dir / target) for target in config_hash_targets if (root_dir / target).exists() }, "commands": command_statuses(), } (bundle_dir / "manifest" / "evidence.json").write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8") PY redact_file "$BUNDLE_DIR/manifest/evidence.json" } write_summary_json() { "$PYTHON_CMD" - "$BUNDLE_DIR" "$ARCHIVE_PATH" <<'PY' import json import sys from pathlib import Path bundle_dir = Path(sys.argv[1]) archive_path = Path(sys.argv[2]) manifest = bundle_dir / "manifest.json" payload = { "bundle_dir": bundle_dir.as_posix(), "archive": archive_path.as_posix(), "manifest": manifest.as_posix(), "archive_exists": archive_path.exists(), "archive_size_bytes": archive_path.stat().st_size if archive_path.exists() else None, } print(json.dumps(payload, indent=2)) PY } collect_system_info collect_config collect_diagnostics collect_compose_validation collect_docker write_evidence write_manifest tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" "$BUNDLE_NAME" if [[ "$JSON_OUTPUT" == "true" ]]; then write_summary_json else echo "ODS support bundle created:" echo " Directory: $BUNDLE_DIR" echo " Archive: $ARCHIVE_PATH" echo "" echo "Review the archive before sharing it publicly." fi