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
334 lines
12 KiB
Python
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,
|
|
}
|