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

547 lines
20 KiB
Python

"""Argo CD REST API client.
Wraps the read-only Argo CD API endpoints used by investigation tools and
integration verification. Credentials come from the local integration store or
from environment variables resolved by ``integrations.catalog``.
"""
from __future__ import annotations
import difflib
import json
import logging
import re
from typing import Any
from urllib.parse import quote
import httpx
from integrations.config_models import ArgoCDIntegrationConfig
from integrations.probes import ProbeResult
from platform.observability.errors.service import capture_service_error
logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = 10.0
_MAX_DIFF_CHARS = 10_000
_SECRET_LINE_RE = re.compile(
r"(?i)(password|passwd|secret|token|api[_-]?key|authorization|bearer)\s*[:=]"
)
_GENERIC_SECRET_VALUE_RE = re.compile(
r"(?i)(bearer\s+[A-Za-z0-9._~+/=-]{6,}"
r"|authorization\s*[:=]\s*\S+"
r"|xox[baprs]-[A-Za-z0-9-]{8,}"
r"|gh[pousr]_[A-Za-z0-9_]{20,}"
r"|AKIA[0-9A-Z]{16}"
r"|-----BEGIN [A-Z ]*PRIVATE KEY-----"
r"|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})"
)
_SENSITIVE_FIELD_RE = re.compile(
r"(?i)(password|passwd|secret|token|api[_-]?key|authorization|auth|private[_-]?key|"
r"client[_-]?secret|connection[_-]?string|credential)"
)
def _normalize_verify_ssl(value: object) -> bool:
if isinstance(value, bool):
return value
if value is None:
return True
text = str(value).strip().lower()
if text in {"0", "false", "no", "off"}:
return False
if text in {"1", "true", "yes", "on"}:
return True
return bool(value)
ArgoCDConfig = ArgoCDIntegrationConfig
class ArgoCDClient:
"""Synchronous read-only client for Argo CD's REST API."""
def __init__(
self,
config: ArgoCDConfig,
*,
transport: httpx.BaseTransport | None = None,
) -> None:
self.config = config
self._transport = transport
self._client: httpx.Client | None = None
self._session_token = ""
self._retired_session_tokens: set[str] = set()
def _get_client(self) -> httpx.Client:
if self._client is None:
self._client = httpx.Client(
base_url=self.config.base_url,
timeout=_DEFAULT_TIMEOUT,
verify=self.config.verify_ssl,
transport=self._transport,
)
return self._client
def close(self) -> None:
"""Close the underlying HTTP connection pool."""
if self._client is not None:
self._client.close()
self._client = None
def __enter__(self) -> ArgoCDClient:
return self
def __exit__(self, *_: object) -> None:
self.close()
@property
def is_configured(self) -> bool:
return self.config.is_configured
def probe_access(self) -> ProbeResult:
"""Validate Argo CD connectivity with a filtered application list call."""
if not self.config.base_url:
return ProbeResult.missing("Missing base_url.")
if not (self.config.bearer_token or (self.config.username and self.config.password)):
return ProbeResult.missing("Missing bearer token or username/password credentials.")
with self:
projects = [self.config.project] if self.config.project else None
result = self.list_applications(projects=projects)
if not result.get("success"):
return ProbeResult.failed(
f"Application list failed: {result.get('error', 'unknown error')}"
)
total = int(result.get("total", 0) or 0)
suffix = "application" if total == 1 else "applications"
return ProbeResult.passed(
f"Connected to Argo CD and listed {total} {suffix}.",
total=total,
)
def _redact(self, value: object) -> str:
text = str(value)
for secret in (
self.config.bearer_token,
self.config.password,
self._session_token,
*self._retired_session_tokens,
):
if secret:
text = text.replace(secret, "[REDACTED]")
text = _GENERIC_SECRET_VALUE_RE.sub("[REDACTED]", text)
return re.sub(
r"(?i)\b(password|passwd|secret|token|api[_-]?key)\b\s+([A-Za-z0-9._~+/=-]{6,})",
r"\1 [REDACTED]",
text,
)
def _error_result(self, prefix: str, exc: Exception) -> dict[str, Any]:
if isinstance(exc, httpx.HTTPStatusError):
response_text = self._redact(exc.response.text[:2000])
return {
"success": False,
"error": f"HTTP {exc.response.status_code}: {response_text}",
}
return {"success": False, "error": self._redact(f"{prefix}: {exc}")}
def _ensure_session_token(self) -> str:
if self.config.bearer_token:
return self.config.bearer_token
if self._session_token:
return self._session_token
if not (self.config.username and self.config.password):
return ""
# Investigation tools issue short-lived read-only calls. If Argo CD expires
# a session token mid-run, _request clears the cached token, retries once,
# and redacts both active and retired tokens in any surfaced error.
response = self._get_client().post(
"/api/v1/session",
json={"username": self.config.username, "password": self.config.password},
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
payload = response.json()
token = str(payload.get("token", "")).strip() if isinstance(payload, dict) else ""
self._session_token = token
return token
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
for attempt in range(2):
request_kwargs = dict(kwargs)
token = self._ensure_session_token()
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
headers.update(request_kwargs.pop("headers", {}) or {})
response = self._get_client().request(method, path, headers=headers, **request_kwargs)
try:
response.raise_for_status()
return response
except httpx.HTTPStatusError as exc:
if (
exc.response.status_code == 401
and self._session_token
and not self.config.bearer_token
):
self._retired_session_tokens.add(self._session_token)
self._session_token = ""
if attempt == 0:
continue
raise
raise RuntimeError("Argo CD request retry exhausted")
def list_applications(
self,
*,
projects: list[str] | None = None,
selector: str = "",
) -> dict[str, Any]:
"""List Argo CD applications, optionally filtered by projects or selector."""
params: dict[str, Any] = {}
cleaned_projects = [
str(project).strip() for project in (projects or []) if str(project).strip()
]
if cleaned_projects:
params["projects"] = ",".join(cleaned_projects)
cleaned_selector = str(selector or "").strip()
if cleaned_selector:
params["selector"] = cleaned_selector
try:
response = self._request("GET", "/api/v1/applications", params=params)
payload = response.json()
items = payload.get("items", []) if isinstance(payload, dict) else []
applications = [
_normalize_application(item) for item in items if isinstance(item, dict)
]
return {"success": True, "applications": applications, "total": len(applications)}
except Exception as exc:
capture_service_error(
exc, logger=logger, integration="argocd", method="list_applications"
)
return self._error_result("list applications failed", exc)
def get_application_summary(
self,
application_name: str,
*,
project: str = "",
app_namespace: str = "",
) -> dict[str, Any]:
"""Fetch one Argo CD application and return a compact status summary."""
name = str(application_name or "").strip()
if not name:
return {"success": False, "error": "application_name is required"}
params = _application_params(
project or self.config.project, app_namespace or self.config.app_namespace
)
try:
response = self._request(
"GET",
f"/api/v1/applications/{quote(name, safe='')}",
params=params,
)
payload = response.json()
if not isinstance(payload, dict):
return {"success": False, "error": "unexpected application response"}
app = _normalize_application(payload)
return {
"success": True,
"application": app,
"recent_history": _recent_history(payload),
}
except Exception as exc:
capture_service_error(
exc,
logger=logger,
integration="argocd",
method="get_application_summary",
extras={"application_name": name},
)
return self._error_result("get application summary failed", exc)
def get_application_diff(
self,
application_name: str,
*,
project: str = "",
app_namespace: str = "",
) -> dict[str, Any]:
"""Fetch Argo CD diff data for one application."""
name = str(application_name or "").strip()
if not name:
return {"success": False, "error": "application_name is required"}
params = _application_params(
project or self.config.project, app_namespace or self.config.app_namespace
)
try:
response = self._request(
"GET",
f"/api/v1/applications/{quote(name, safe='')}/server-side-diff",
params=params,
)
payload = response.json()
raw_diffs: list[Any] = []
payload_modified = False
if isinstance(payload, dict):
# Argo CD v3.3 exposes this response as {items, modified}; keep
# accepting the older/tested {diffs} shape for compatibility.
raw_diffs = payload.get("diffs") or payload.get("items") or []
payload_modified = bool(payload.get("modified"))
diffs = [
_normalize_diff(diff)
for diff in raw_diffs
if isinstance(diff, dict) and _resource_diff_is_modified(diff)
]
if not diffs:
diffs = self._get_managed_resource_diffs(name, params=params)
return {
"success": True,
"application_name": name,
"drift_detected": bool(diffs) or payload_modified,
"diffs": diffs,
"diff_count": len(diffs),
}
except Exception as exc:
capture_service_error(
exc,
logger=logger,
integration="argocd",
method="get_application_diff",
extras={"application_name": name},
)
return self._error_result("get application diff failed", exc)
def _get_managed_resource_diffs(
self,
application_name: str,
*,
params: dict[str, str],
) -> list[dict[str, Any]]:
"""Derive drift from Argo CD managed resource target/live states."""
response = self._request(
"GET",
f"/api/v1/applications/{quote(application_name, safe='')}/managed-resources",
params=params,
)
payload = response.json()
raw_items = payload.get("items", []) if isinstance(payload, dict) else []
diffs: list[dict[str, Any]] = []
for item in raw_items:
if not isinstance(item, dict):
continue
diff = _managed_resource_diff(item)
if diff:
diffs.append(_normalize_diff(diff))
return diffs
def _application_params(project: str = "", app_namespace: str = "") -> dict[str, str]:
params: dict[str, str] = {}
if project:
params["project"] = project
if app_namespace:
params["appNamespace"] = app_namespace
return params
def _normalize_application(payload: dict[str, Any]) -> dict[str, Any]:
metadata = payload.get("metadata", {}) if isinstance(payload.get("metadata"), dict) else {}
spec = payload.get("spec", {}) if isinstance(payload.get("spec"), dict) else {}
source = spec.get("source", {}) if isinstance(spec.get("source"), dict) else {}
destination = spec.get("destination", {}) if isinstance(spec.get("destination"), dict) else {}
status = payload.get("status", {}) if isinstance(payload.get("status"), dict) else {}
sync = status.get("sync", {}) if isinstance(status.get("sync"), dict) else {}
health = status.get("health", {}) if isinstance(status.get("health"), dict) else {}
operation_state = (
status.get("operationState", {}) if isinstance(status.get("operationState"), dict) else {}
)
sync_result = (
operation_state.get("syncResult", {})
if isinstance(operation_state.get("syncResult"), dict)
else {}
)
history = status.get("history", []) if isinstance(status.get("history"), list) else []
summary = status.get("summary", {}) if isinstance(status.get("summary"), dict) else {}
revision = (
str(sync.get("revision") or "").strip()
or str(sync_result.get("revision") or "").strip()
or _history_revision(history)
)
return {
"name": str(metadata.get("name", "")).strip(),
"namespace": str(metadata.get("namespace", "")).strip(),
"project": str(spec.get("project", "")).strip(),
"repo_url": str(source.get("repoURL", "")).strip(),
"target_revision": str(source.get("targetRevision", "")).strip(),
"destination_server": str(destination.get("server", "")).strip(),
"destination_namespace": str(destination.get("namespace", "")).strip(),
"sync_status": str(sync.get("status", "")).strip(),
"health_status": str(health.get("status", "")).strip(),
"health_message": str(health.get("message", "")).strip(),
"revision": revision,
"operation_phase": str(operation_state.get("phase", "")).strip(),
"operation_message": str(operation_state.get("message", "")).strip(),
"images": list(summary.get("images", []) or [])
if isinstance(summary.get("images", []), list)
else [],
"history_count": len(history),
}
def _history_revision(history: list[Any]) -> str:
for item in reversed(history):
if isinstance(item, dict) and item.get("revision"):
return str(item["revision"]).strip()
return ""
def _recent_history(payload: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]:
status = payload.get("status", {}) if isinstance(payload.get("status"), dict) else {}
history = status.get("history", []) if isinstance(status.get("history"), list) else []
recent = [item for item in reversed(history) if isinstance(item, dict)]
return recent[:limit]
def _resource_diff_is_modified(payload: dict[str, Any]) -> bool:
if "modified" in payload:
return bool(payload.get("modified"))
return bool(
payload.get("diff")
or payload.get("liveState")
or payload.get("normalizedLiveState")
or payload.get("targetState")
or payload.get("predictedLiveState")
)
def _managed_resource_diff(payload: dict[str, Any]) -> dict[str, Any] | None:
desired = str(payload.get("predictedLiveState") or payload.get("targetState") or "")
live = str(payload.get("normalizedLiveState") or payload.get("liveState") or "")
desired_state = _parse_json_state(desired)
live_state = _parse_json_state(live)
if desired_state is None or live_state is None:
# Argo CD managed resource states are expected to be JSON. Avoid
# synthesizing diffs from plain text/YAML where formatting alone can
# look like drift.
return None
desired_lines = _json_state_lines(desired_state)
live_lines = _json_state_lines(live_state)
if desired_lines == live_lines:
return None
if _json_contains_sensitive_data(desired_state) or _json_contains_sensitive_data(live_state):
rendered_diff = "[REDACTED secret-bearing resource diff]"
else:
rendered_diff = _render_state_diff(desired_lines, live_lines)
return {
"group": payload.get("group", ""),
"kind": payload.get("kind", ""),
"name": payload.get("name", ""),
"namespace": payload.get("namespace", ""),
"diff": rendered_diff,
}
def _parse_json_state(value: str) -> Any | None:
text = str(value or "").strip()
if not text:
return None
try:
return json.loads(text)
except json.JSONDecodeError:
return None
def _json_state_lines(value: Any) -> list[str]:
return json.dumps(value, indent=2, sort_keys=True).splitlines()
def _json_contains_sensitive_data(value: Any) -> bool:
if isinstance(value, dict):
for key, nested in value.items():
if _SENSITIVE_FIELD_RE.search(str(key)):
return True
if _json_contains_sensitive_data(nested):
return True
return False
if isinstance(value, list):
return any(_json_contains_sensitive_data(item) for item in value)
if isinstance(value, str):
return bool(_GENERIC_SECRET_VALUE_RE.search(value))
return False
def _render_state_diff(desired_lines: list[str], live_lines: list[str]) -> str:
return "\n".join(
difflib.unified_diff(
desired_lines,
live_lines,
fromfile="desired",
tofile="live",
lineterm="",
)
)
def _sanitize_diff(value: object, *, resource_kind: str = "") -> str:
if str(resource_kind or "").strip().lower() == "secret":
return "[REDACTED Kubernetes Secret diff]"
lines: list[str] = []
for line in str(value or "").splitlines():
if _SECRET_LINE_RE.search(line) or _GENERIC_SECRET_VALUE_RE.search(line):
lines.append("[REDACTED secret-bearing diff line]")
else:
lines.append(line)
text = "\n".join(lines)
if len(text) > _MAX_DIFF_CHARS:
return f"{text[:_MAX_DIFF_CHARS]}\n[truncated after {_MAX_DIFF_CHARS} chars]"
return text
def _normalize_diff(payload: dict[str, Any]) -> dict[str, Any]:
return {
"group": str(payload.get("group", "")).strip(),
"kind": str(payload.get("kind", "")).strip(),
"name": str(payload.get("name", "")).strip(),
"namespace": str(payload.get("namespace", "")).strip(),
"diff": _sanitize_diff(payload.get("diff", ""), resource_kind=payload.get("kind", "")),
}
def make_argocd_client(
base_url: str | None,
bearer_token: str | None = None,
username: str | None = None,
password: str | None = None,
*,
project: str | None = None,
app_namespace: str | None = None,
verify_ssl: bool | str = True,
) -> ArgoCDClient | None:
"""Create an ArgoCDClient when a URL and auth method are available."""
url = (base_url or "").strip().rstrip("/")
token = (bearer_token or "").strip()
user = (username or "").strip()
pw = (password or "").strip()
if not url or not (token or (user and pw)):
return None
try:
return ArgoCDClient(
ArgoCDConfig(
base_url=url,
bearer_token=token,
username=user,
password=pw,
project=project or "",
app_namespace=app_namespace or "",
verify_ssl=_normalize_verify_ssl(verify_ssl),
)
)
except Exception:
return None