Files
wehub-resource-sync 4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

334 lines
12 KiB
Python

"""Helm CLI client — read-only release inspection for investigations."""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
from pathlib import Path
from typing import Any
from integrations.config_models import HelmIntegrationConfig
from integrations.probes import ProbeResult
from platform.notifications.limits import MAX_MESSAGE_SIZE
logger = logging.getLogger(__name__)
_DEFAULT_CMD_TIMEOUT = 90.0
_PROBE_LIST_TIMEOUT = 45.0
_PROBE_VERSION_TIMEOUT = 15.0
_DEFAULT_MANIFEST_CHARS = 600_000
def _helm_client_major_version(version_client_output: str) -> int | None:
"""Best-effort major Helm client version from ``helm version`` stdout."""
text = version_client_output
if m := re.search(r'SemVer:"v(\d+)', text):
return int(m.group(1))
if m := re.search(r'Version:"v(\d+)', text):
return int(m.group(1))
return None
def _manifest_char_cap() -> int:
"""Max manifest size; override with HELM_MANIFEST_MAX_CHARS (integer, min 1024)."""
raw = (os.getenv("HELM_MANIFEST_MAX_CHARS") or "").strip()
if raw.isdigit():
return max(1024, int(raw))
return _DEFAULT_MANIFEST_CHARS
class HelmClient:
"""Runs Helm 3 CLI commands with explicit kubeconfig/context and timeouts.
Requires Helm 3.x (``helm version`` is checked during :meth:`probe_access`).
Environment:
``HELM_MANIFEST_MAX_CHARS`` — optional integer; minimum 1024; caps ``get_manifest``
output size (default 600_000). When truncated, the result sets ``truncated=True``.
"""
def __init__(self, config: HelmIntegrationConfig) -> None:
self._config = config
@property
def is_configured(self) -> bool:
return self._resolved_helm_path() is not None
def _resolved_helm_path(self) -> str | None:
raw = (self._config.helm_path or "helm").strip() or "helm"
candidate = Path(raw).expanduser()
if candidate.is_file():
return str(candidate)
return shutil.which(raw)
def _kube_flags(self) -> list[str]:
flags: list[str] = []
ctx = self._config.kube_context.strip()
if ctx:
flags.extend(["--kube-context", ctx])
kc = self._config.kubeconfig.strip()
if kc:
flags.extend(["--kubeconfig", str(Path(kc).expanduser())])
return flags
def _base_cmd(self) -> list[str] | None:
hp = self._resolved_helm_path()
if hp is None:
return None
return [hp, *self._kube_flags()]
def _run(self, args: list[str], *, timeout: float) -> tuple[int, str, str]:
base = self._base_cmd()
if base is None:
path_hint = (self._config.helm_path or "helm").strip() or "helm"
return 127, "", f"helm executable not found ({path_hint!r})"
cmd = [*base, *args]
logger.debug("helm subprocess: %s subcommands", len(args))
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
check=False,
env=os.environ.copy(),
)
except subprocess.TimeoutExpired:
return 124, "", "helm command timed out"
except OSError as exc:
return 1, "", f"helm subprocess failed: {exc}"
return proc.returncode, proc.stdout or "", proc.stderr or ""
def probe_access(self) -> ProbeResult:
if self._resolved_helm_path() is None:
path = (self._config.helm_path or "helm").strip() or "helm"
return ProbeResult.missing(
f"Helm binary not found ({path!r}). Install Helm or set helm_path to a binary."
)
code, ver_out, err = self._run(["version"], timeout=_PROBE_VERSION_TIMEOUT)
if code != 0:
detail = (err or "unknown error").strip()
return ProbeResult.failed(f"helm version failed (exit {code}): {detail}")
combined_ver = f"{ver_out}\n{err or ''}"
major = _helm_client_major_version(combined_ver)
if major is not None and major < 3:
return ProbeResult.failed(
"Helm 3.x is required for this integration; `helm version` "
f"reports a Helm {major}.x client. Install Helm 3 or point helm_path at a Helm 3 "
"binary."
)
code, out, err = self._run(
["list", "-A", "--max", "1", "-o", "json"],
timeout=_PROBE_LIST_TIMEOUT,
)
if code != 0:
detail = (err or out or "cluster unreachable or kubeconfig missing").strip()
return ProbeResult.failed(f"Helm cannot list releases: {detail}")
stdout = (out or "").strip()
if not stdout:
return ProbeResult.failed(
"Helm list returned empty output; expected a JSON array of releases."
)
try:
parsed = json.loads(stdout)
except json.JSONDecodeError as exc:
snippet = stdout[:200].replace("\n", " ")
return ProbeResult.failed(
f"Helm list output is not valid JSON ({exc}; stdout starts with {snippet!r})"
)
if not isinstance(parsed, list):
return ProbeResult.failed(
"Helm list -o json must return a JSON array of releases, "
f"not {type(parsed).__name__}."
)
return ProbeResult.passed("Helm CLI is available and can reach the Kubernetes cluster.")
def list_releases(
self,
*,
namespace: str = "",
all_namespaces: bool = False,
max_releases: int = 256,
) -> dict[str, Any]:
cap = max(1, min(max_releases, MAX_MESSAGE_SIZE))
args = ["list", "-o", "json", "--max", str(cap)]
if all_namespaces:
args.append("-A")
elif namespace.strip():
args.extend(["-n", namespace.strip()])
else:
args.append("-A")
ns_filter = namespace.strip()
# Match the branches above: `-A` when listing every namespace (explicit flag or none set).
listed_all_namespaces = bool(all_namespaces) or not bool(ns_filter)
code, out, err = self._run(args, timeout=_DEFAULT_CMD_TIMEOUT)
if code != 0:
return {
"success": False,
"error": (err or out).strip(),
"releases": [],
"all_namespaces": listed_all_namespaces,
"namespace": ns_filter,
}
try:
parsed = json.loads(out or "[]")
except json.JSONDecodeError:
return {
"success": False,
"error": "invalid JSON from helm list",
"releases": [],
"all_namespaces": listed_all_namespaces,
"namespace": ns_filter,
}
if not isinstance(parsed, list):
return {
"success": False,
"error": "unexpected helm list shape",
"releases": [],
"all_namespaces": listed_all_namespaces,
"namespace": ns_filter,
}
return {
"success": True,
"error": "",
"releases": parsed,
"all_namespaces": listed_all_namespaces,
"namespace": ns_filter,
}
def release_status(self, release: str, namespace: str) -> dict[str, Any]:
rel = release.strip()
ns = namespace.strip() or "default"
if not rel:
return {"success": False, "error": "release name is required", "status": {}}
code, out, err = self._run(
["status", rel, "-n", ns, "-o", "json"],
timeout=_DEFAULT_CMD_TIMEOUT,
)
if code != 0:
return {"success": False, "error": (err or out).strip(), "status": {}}
try:
payload = json.loads(out)
except json.JSONDecodeError:
return {"success": False, "error": "invalid JSON from helm status", "status": {}}
if not isinstance(payload, dict):
return {"success": False, "error": "unexpected helm status shape", "status": {}}
return {
"success": True,
"error": "",
"release": rel,
"namespace": ns,
"status": payload,
}
def release_history(
self,
release: str,
namespace: str,
*,
max_revisions: int = 10,
) -> dict[str, Any]:
rel = release.strip()
ns = namespace.strip() or "default"
limit = max(1, min(max_revisions, 64))
if not rel:
return {"success": False, "error": "release name is required", "history": []}
code, out, err = self._run(
["history", rel, "-n", ns, "-o", "json", "--max", str(limit)],
timeout=_DEFAULT_CMD_TIMEOUT,
)
if code != 0:
return {"success": False, "error": (err or out).strip(), "history": []}
try:
parsed = json.loads(out or "[]")
except json.JSONDecodeError:
return {"success": False, "error": "invalid JSON from helm history", "history": []}
if not isinstance(parsed, list):
return {"success": False, "error": "unexpected helm history shape", "history": []}
return {
"success": True,
"error": "",
"release": rel,
"namespace": ns,
"history": parsed,
}
def get_values(
self,
release: str,
namespace: str,
*,
all_values: bool = False,
) -> dict[str, Any]:
rel = release.strip()
ns = namespace.strip() or "default"
if not rel:
return {"success": False, "error": "release name is required", "values": {}}
args = ["get", "values", rel, "-n", ns, "-o", "json"]
if all_values:
args.append("--all")
code, out, err = self._run(args, timeout=_DEFAULT_CMD_TIMEOUT)
if code != 0:
return {"success": False, "error": (err or out).strip(), "values": {}}
try:
raw = json.loads(out or "{}")
except json.JSONDecodeError:
return {"success": False, "error": "invalid JSON from helm get values", "values": {}}
# helm get values -o json emits JSON null when the release has no user-supplied values.
if raw is None:
raw = {}
if not isinstance(raw, dict):
return {"success": False, "error": "unexpected helm values shape", "values": {}}
parsed = raw
return {
"success": True,
"error": "",
"release": rel,
"namespace": ns,
"values": parsed,
"all_values": all_values,
}
def get_manifest(self, release: str, namespace: str) -> dict[str, Any]:
rel = release.strip()
ns = namespace.strip() or "default"
if not rel:
return {"success": False, "error": "release name is required", "manifest": ""}
code, out, err = self._run(
["get", "manifest", rel, "-n", ns],
timeout=_DEFAULT_CMD_TIMEOUT,
)
if code != 0:
return {
"success": False,
"error": (err or out).strip(),
"manifest": "",
"truncated": False,
}
text = out or ""
truncated = False
cap = _manifest_char_cap()
if len(text) > cap:
text = text[:cap]
truncated = True
return {
"success": True,
"error": "",
"release": rel,
"namespace": ns,
"manifest": text,
"truncated": truncated,
}