9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
786 lines
23 KiB
Bash
Executable File
786 lines
23 KiB
Bash
Executable File
#!/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" <<EOF
|
|
selected_bash=$BASH_CMD
|
|
selected_bash_version=$("$BASH_CMD" -c 'printf "%s\n" "${BASH_VERSION:-unknown}"' 2>/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
|