Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,562 @@
|
||||
"""Client for the private, typed deployment broker surface.
|
||||
|
||||
The client only sends a canonical DeploymentPlanV1 to a configured controller.
|
||||
It neither exposes a command field nor calls a Docker socket/Portainer exec
|
||||
endpoint. The controller independently validates repository, revision and
|
||||
runtime topology before it can mutate production.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ..core.config import settings
|
||||
from ..core.redaction import redact_message
|
||||
from ..schemas.deployment import DeploymentPlanV1
|
||||
|
||||
_REQUEST_ID = re.compile(r"^[0-9a-f]{64}$")
|
||||
_MAX_DELIVERY_STATUS_BYTES = 512 * 1024
|
||||
_MAX_RUNNER_FLEET_STATUS_BYTES = 512 * 1024
|
||||
_MAX_CONTROLLER_HEALTH_BYTES = 64 * 1024
|
||||
_WORKMESH_REVISION = re.compile(r"^[0-9a-f]{40}$")
|
||||
_WORKMESH_PREFLIGHT_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_WORKMESH_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
|
||||
|
||||
|
||||
class DeploymentBrokerClient:
|
||||
"""Minimal HMAC client for the controller's versioned deployment API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
verify_tls: bool | None = None,
|
||||
ca_file: str | None = None,
|
||||
):
|
||||
configured_url = base_url or settings.apis.get("DEPLOYMENT_BROKER_URL")
|
||||
self.base_url = str(configured_url or "").strip().rstrip("/")
|
||||
self.verify_tls = (
|
||||
bool(settings.apis.get("DEPLOYMENT_BROKER_VERIFY_TLS"))
|
||||
if verify_tls is None
|
||||
else verify_tls
|
||||
)
|
||||
configured_ca_file = (
|
||||
settings.apis.get("DEPLOYMENT_BROKER_CA_FILE")
|
||||
if ca_file is None
|
||||
else ca_file
|
||||
)
|
||||
self.ca_file = str(configured_ca_file or "").strip() or None
|
||||
self.timeout_seconds = max(
|
||||
2.0,
|
||||
min(30.0, float(settings.apis.get("DEPLOYMENT_BROKER_TIMEOUT_SECONDS") or 10)),
|
||||
)
|
||||
|
||||
def configuration_error(self) -> str | None:
|
||||
if not self.base_url:
|
||||
return "deployment_broker_url_missing"
|
||||
parsed = urlparse(self.base_url)
|
||||
loopback_http = False
|
||||
if parsed.scheme == "http":
|
||||
hostname = parsed.hostname or ""
|
||||
try:
|
||||
loopback_http = ipaddress.ip_address(hostname).is_loopback
|
||||
except ValueError:
|
||||
loopback_http = hostname.lower() == "localhost"
|
||||
if (
|
||||
not (parsed.scheme == "https" or loopback_http)
|
||||
or not parsed.netloc
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
return "deployment_broker_url_invalid"
|
||||
if parsed.scheme == "https":
|
||||
if not self.verify_tls:
|
||||
return "deployment_broker_tls_verification_required"
|
||||
if self.ca_file and not self._trusted_ca_file_is_safe(self.ca_file):
|
||||
return "deployment_broker_ca_file_invalid"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _trusted_ca_file_is_safe(value: str) -> bool:
|
||||
"""Require an AppOps-owned readable CA bundle that others cannot write."""
|
||||
path = Path(value)
|
||||
if not path.is_absolute():
|
||||
return False
|
||||
try:
|
||||
info = path.lstat()
|
||||
except OSError:
|
||||
return False
|
||||
if (
|
||||
path.is_symlink()
|
||||
or not stat.S_ISREG(info.st_mode)
|
||||
or not 1 <= info.st_size <= 1024 * 1024
|
||||
):
|
||||
return False
|
||||
if os.name != "nt":
|
||||
return (
|
||||
info.st_uid == os.geteuid()
|
||||
and not (stat.S_IMODE(info.st_mode) & 0o022)
|
||||
and os.access(path, os.R_OK)
|
||||
)
|
||||
return True
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return self.configuration_error() is None
|
||||
|
||||
def _verify_option(self) -> bool | str:
|
||||
parsed = urlparse(self.base_url)
|
||||
if parsed.scheme == "https" and self.ca_file:
|
||||
return self.ca_file
|
||||
return self.verify_tls
|
||||
|
||||
@staticmethod
|
||||
def _canonical_payload(payload: dict[str, Any]) -> bytes:
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _signature(
|
||||
secret: str,
|
||||
method: str,
|
||||
path: str,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
body: bytes,
|
||||
) -> str:
|
||||
"""Bind the HMAC to one versioned HTTP request, not merely its body."""
|
||||
body_digest = hashlib.sha256(body).hexdigest()
|
||||
signed = (
|
||||
f"itworx-controller-request/v2\n{method.upper()}\n{path}\n"
|
||||
f"{timestamp}\n{nonce}\n{body_digest}"
|
||||
).encode("ascii")
|
||||
return hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
secret: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
error_response_fields: frozenset[str] = frozenset(),
|
||||
) -> dict[str, Any]:
|
||||
if not self.is_configured():
|
||||
return {"status": "missing_config", "reason": self.configuration_error()}
|
||||
if not secret or len(secret) < 32:
|
||||
return {"status": "missing_secret", "reason": "deployment_broker_secret_missing_or_weak"}
|
||||
body = self._canonical_payload(payload) if payload is not None else b""
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = uuid.uuid4().hex
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"X-ITWorx-Deployment-Timestamp": timestamp,
|
||||
"X-ITWorx-Deployment-Nonce": nonce,
|
||||
"X-ITWorx-Deployment-Signature": self._signature(
|
||||
secret, method, path, timestamp, nonce, body
|
||||
),
|
||||
}
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=self.timeout_seconds,
|
||||
verify=self._verify_option(),
|
||||
) as client:
|
||||
response = client.request(method, f"{self.base_url}{path}", content=body, headers=headers)
|
||||
except httpx.RequestError as exc:
|
||||
return {"status": "unreachable", "reason": redact_message(str(exc))}
|
||||
if response.status_code in {401, 403}:
|
||||
return {"status": "unauthorized", "reason": "deployment_broker_authentication_failed"}
|
||||
try:
|
||||
decoded = response.json()
|
||||
except ValueError:
|
||||
decoded = {}
|
||||
if not isinstance(decoded, dict):
|
||||
decoded = {}
|
||||
if response.status_code >= 400:
|
||||
rejected = {
|
||||
"status": str(decoded.get("status") or "rejected"),
|
||||
"reason": redact_message(str(decoded.get("reason") or f"deployment_broker_http_{response.status_code}")),
|
||||
}
|
||||
for field in error_response_fields:
|
||||
value = decoded.get(field)
|
||||
if isinstance(value, str) and len(value) <= 256:
|
||||
rejected[field] = value
|
||||
return rejected
|
||||
return {"status": str(decoded.get("status") or "ok"), **decoded}
|
||||
|
||||
def controller_health(self) -> dict[str, Any]:
|
||||
"""Read the bounded root-controller health and release identity."""
|
||||
if not self.is_configured():
|
||||
return {"status": "missing_config", "reason": self.configuration_error()}
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=self.timeout_seconds,
|
||||
verify=self._verify_option(),
|
||||
follow_redirects=False,
|
||||
) as client, client.stream(
|
||||
"GET",
|
||||
f"{self.base_url}/health",
|
||||
headers={"Accept": "application/json"},
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
return {
|
||||
"status": "unavailable",
|
||||
"reason": f"controller_health_http_{response.status_code}",
|
||||
}
|
||||
body = bytearray()
|
||||
for chunk in response.iter_bytes():
|
||||
body.extend(chunk)
|
||||
if len(body) > _MAX_CONTROLLER_HEALTH_BYTES:
|
||||
return {
|
||||
"status": "unavailable",
|
||||
"reason": "controller_health_response_too_large",
|
||||
}
|
||||
except httpx.RequestError as exc:
|
||||
return {"status": "unreachable", "reason": redact_message(str(exc))}
|
||||
try:
|
||||
decoded = json.loads(bytes(body).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return {"status": "unavailable", "reason": "controller_health_invalid_response"}
|
||||
return (
|
||||
decoded
|
||||
if isinstance(decoded, dict)
|
||||
else {"status": "unavailable", "reason": "controller_health_invalid_response"}
|
||||
)
|
||||
|
||||
def preflight(self, plan: DeploymentPlanV1, *, secret: str) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/deployments/preflight",
|
||||
secret=secret,
|
||||
payload={"plan": plan.model_dump(mode="json")},
|
||||
)
|
||||
|
||||
def appops_self_upgrade_preflight(
|
||||
self, revision: str, reason: str, expected_current_revision: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Ask the private controller to validate one fixed protected-main target."""
|
||||
return self._request(
|
||||
"POST", "/v1/appops/self-upgrade/preflight",
|
||||
secret=settings.mcp_token or "",
|
||||
payload={"repository": "jens/unraid-appops-gateway", "revision": revision,
|
||||
"reason": reason, "expectedCurrentRevision": expected_current_revision},
|
||||
)
|
||||
|
||||
def appops_self_upgrade_apply(
|
||||
self, revision: str, reason: str, expected_current_revision: str | None,
|
||||
preflight_sha256: str, idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"POST", "/v1/appops/self-upgrade/apply",
|
||||
secret=settings.mcp_token or "",
|
||||
payload={"repository": "jens/unraid-appops-gateway", "revision": revision,
|
||||
"reason": reason, "expectedCurrentRevision": expected_current_revision,
|
||||
"preflightSha256": preflight_sha256, "idempotencyKey": idempotency_key},
|
||||
)
|
||||
|
||||
def appops_self_upgrade_status(self, operation_id: str) -> dict[str, Any]:
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", operation_id):
|
||||
return {"status": "invalid_operation_id"}
|
||||
return self._request(
|
||||
"GET", f"/v1/appops/self-upgrade/status/{operation_id}",
|
||||
secret=settings.mcp_token or "",
|
||||
)
|
||||
|
||||
def apply(self, plan: DeploymentPlanV1, *, secret: str, idempotency_key: str) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/deployments/apply",
|
||||
secret=secret,
|
||||
payload={"plan": plan.model_dump(mode="json"), "idempotencyKey": idempotency_key},
|
||||
)
|
||||
|
||||
def rollback(self, plan: DeploymentPlanV1, *, secret: str, idempotency_key: str) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/deployments/rollback",
|
||||
secret=secret,
|
||||
payload={"plan": plan.model_dump(mode="json"), "idempotencyKey": idempotency_key},
|
||||
)
|
||||
|
||||
def enrollment_preflight(self, enrollment: dict[str, Any], *, secret: str) -> dict[str, Any]:
|
||||
"""Validate a bounded target enrollment with the controller, read-only."""
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/enrollments/preflight",
|
||||
secret=secret,
|
||||
payload={"enrollment": enrollment},
|
||||
)
|
||||
|
||||
def enrollment_capabilities(self, *, secret: str) -> dict[str, Any]:
|
||||
"""Read the bootstrap-protected enrollment controller capability."""
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/enrollments/capabilities",
|
||||
secret=secret,
|
||||
payload={},
|
||||
)
|
||||
|
||||
def repopulse_adoption_capabilities(self, *, secret: str) -> dict[str, Any]:
|
||||
"""Read fixed RepoPulse adoption readiness through bootstrap HMAC v2."""
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/repopulse/adoption/capabilities",
|
||||
secret=secret,
|
||||
payload={},
|
||||
)
|
||||
|
||||
def repopulse_adoption_reserve(
|
||||
self,
|
||||
*,
|
||||
secret: str,
|
||||
intent_id: str,
|
||||
idempotency_sha256: str,
|
||||
preflight_sha256: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Reserve the fixed controller intent through bootstrap HMAC v2."""
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/repopulse/adoption/reserve",
|
||||
secret=secret,
|
||||
payload={
|
||||
"intentId": intent_id,
|
||||
"idempotencySha256": idempotency_sha256,
|
||||
"preflightSha256": preflight_sha256,
|
||||
},
|
||||
)
|
||||
|
||||
def repopulse_adoption_status(self, *, secret: str) -> dict[str, Any]:
|
||||
"""Read sanitized fixed adoption state through bootstrap HMAC v2."""
|
||||
return self._request(
|
||||
"GET",
|
||||
"/v1/appops/repopulse/adoption/status",
|
||||
secret=secret,
|
||||
)
|
||||
|
||||
def repopulse_adoption_execute(
|
||||
self,
|
||||
*,
|
||||
secret: str,
|
||||
intent_id: str,
|
||||
idempotency_sha256: str,
|
||||
preflight_sha256: str,
|
||||
preflight_projection: dict[str, Any],
|
||||
target_credential: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Request the one fixed migration; the controller owns every effect."""
|
||||
if urlparse(self.base_url).scheme != "https" or self.configuration_error() is not None:
|
||||
return {
|
||||
"status": "missing_config",
|
||||
"reason": "repopulse_adoption_private_tls_required",
|
||||
}
|
||||
if (
|
||||
not isinstance(target_credential, str)
|
||||
or not 32 <= len(target_credential) <= 256
|
||||
or not target_credential.isascii()
|
||||
or not target_credential.isprintable()
|
||||
):
|
||||
return {"status": "invalid", "reason": "repopulse_target_credential_invalid"}
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/repopulse/adoption/execute",
|
||||
secret=secret,
|
||||
payload={
|
||||
"intentId": intent_id,
|
||||
"idempotencySha256": idempotency_sha256,
|
||||
"preflightSha256": preflight_sha256,
|
||||
"preflightProjection": preflight_projection,
|
||||
"targetCredential": target_credential,
|
||||
},
|
||||
)
|
||||
|
||||
def enroll_target(
|
||||
self,
|
||||
enrollment: dict[str, Any],
|
||||
*,
|
||||
secret: str,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Create exactly one controller-owned target through bootstrap HMAC."""
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/enrollments",
|
||||
secret=secret,
|
||||
payload={"enrollment": enrollment, "idempotencyKey": idempotency_key},
|
||||
)
|
||||
|
||||
def configure_enrollment_runtime(
|
||||
self,
|
||||
runtime: dict[str, Any],
|
||||
*,
|
||||
secret: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Write profile-owned runtime values using that target's own HMAC."""
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/enrollments/runtime",
|
||||
secret=secret,
|
||||
payload={"runtime": runtime},
|
||||
)
|
||||
|
||||
def unenroll_target(
|
||||
self,
|
||||
target: dict[str, Any],
|
||||
*,
|
||||
secret: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Compensate an untouched target through the bootstrap boundary only."""
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/enrollments/rollback",
|
||||
secret=secret,
|
||||
payload={"target": target},
|
||||
)
|
||||
|
||||
def status(self, request_id: str, *, secret: str) -> dict[str, Any]:
|
||||
if not _REQUEST_ID.fullmatch(request_id):
|
||||
return {"status": "invalid", "reason": "invalid_broker_request_id"}
|
||||
return self._request(
|
||||
"GET",
|
||||
f"/v1/appops/deployments/{request_id}",
|
||||
secret=secret,
|
||||
)
|
||||
|
||||
def workmesh_promotion_preflight(self, revision: str, *, secret: str) -> dict[str, Any]:
|
||||
"""Preflight only the fixed WorkMesh product target at one full SHA."""
|
||||
if not _WORKMESH_REVISION.fullmatch(revision):
|
||||
return {"status": "invalid", "reason": "invalid_workmesh_revision"}
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/workmesh/promotions/preflight",
|
||||
secret=secret,
|
||||
payload={"revision": revision},
|
||||
)
|
||||
|
||||
def workmesh_promotion_apply(
|
||||
self,
|
||||
revision: str,
|
||||
*,
|
||||
secret: str,
|
||||
preflight_sha256: str,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Queue a promotion only when its approved preflight digest still matches."""
|
||||
if not _WORKMESH_REVISION.fullmatch(revision):
|
||||
return {"status": "invalid", "reason": "invalid_workmesh_revision"}
|
||||
if not _WORKMESH_PREFLIGHT_SHA256.fullmatch(preflight_sha256):
|
||||
return {"status": "invalid", "reason": "invalid_workmesh_preflight_digest"}
|
||||
if not _WORKMESH_IDEMPOTENCY.fullmatch(idempotency_key):
|
||||
return {"status": "invalid", "reason": "invalid_workmesh_idempotency_key"}
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/workmesh/promotions/apply",
|
||||
secret=secret,
|
||||
payload={
|
||||
"revision": revision,
|
||||
"preflightSha256": preflight_sha256,
|
||||
"idempotencyKey": idempotency_key,
|
||||
},
|
||||
error_response_fields=frozenset({"request", "repository", "revision", "operation"}),
|
||||
)
|
||||
|
||||
def workmesh_promotion_status(self, request_id: str, *, secret: str) -> dict[str, Any]:
|
||||
"""Read one HMAC-authenticated fixed-target promotion receipt projection."""
|
||||
return self.status(request_id, secret=secret)
|
||||
|
||||
def delivery_status(self) -> dict[str, Any]:
|
||||
"""Read bounded, secret-free runnerless delivery health from controller.
|
||||
|
||||
This endpoint deliberately sends no deployment credential: the
|
||||
controller publishes only a private-network, read-only projection of
|
||||
its durable webhook inbox. All mutations keep using the HMAC-bound
|
||||
typed request surface above.
|
||||
"""
|
||||
if not self.is_configured():
|
||||
return {"status": "missing_config", "reason": self.configuration_error()}
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=self.timeout_seconds,
|
||||
verify=self._verify_option(),
|
||||
follow_redirects=False,
|
||||
) as client, client.stream(
|
||||
"GET",
|
||||
f"{self.base_url}/v1/gitea/deliveries",
|
||||
headers={"Accept": "application/json"},
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
return {"status": "unavailable", "reason": f"delivery_status_http_{response.status_code}"}
|
||||
body = bytearray()
|
||||
for chunk in response.iter_bytes():
|
||||
body.extend(chunk)
|
||||
if len(body) > _MAX_DELIVERY_STATUS_BYTES:
|
||||
return {"status": "unavailable", "reason": "delivery_status_response_too_large"}
|
||||
except httpx.RequestError as exc:
|
||||
return {"status": "unreachable", "reason": redact_message(str(exc))}
|
||||
try:
|
||||
decoded = json.loads(bytes(body).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return {"status": "unavailable", "reason": "delivery_status_invalid_response"}
|
||||
return decoded if isinstance(decoded, dict) else {"status": "unavailable", "reason": "delivery_status_invalid_response"}
|
||||
|
||||
def runner_fleet_upgrade_v2(self, *, reason: str) -> dict[str, Any]:
|
||||
"""Request the one fixed Tower runner-fleet v2 migration.
|
||||
|
||||
The root controller derives and verifies the exact protected-main
|
||||
revision itself. The caller cannot select a script path, runner name,
|
||||
revision or Docker target.
|
||||
"""
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/appops/runner-fleet/upgrade-v2",
|
||||
secret=settings.mcp_token or "",
|
||||
payload={"reason": reason[:1000]},
|
||||
)
|
||||
|
||||
def runner_fleet_status(self) -> dict[str, Any]:
|
||||
"""Read the controller's private, bounded CI sandbox projection.
|
||||
|
||||
This is deliberately read-only and sends no credential. The controller
|
||||
derives the payload from fixed runner identities and its private DinD
|
||||
daemon; AppOps never receives a Docker socket or a cleanup selector.
|
||||
"""
|
||||
if not self.is_configured():
|
||||
return {"status": "missing_config", "reason": self.configuration_error()}
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=self.timeout_seconds,
|
||||
verify=self._verify_option(),
|
||||
follow_redirects=False,
|
||||
) as client, client.stream(
|
||||
"GET",
|
||||
f"{self.base_url}/v1/runner-fleet",
|
||||
headers={"Accept": "application/json"},
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
return {"status": "unavailable", "reason": f"runner_fleet_status_http_{response.status_code}"}
|
||||
body = bytearray()
|
||||
for chunk in response.iter_bytes():
|
||||
body.extend(chunk)
|
||||
if len(body) > _MAX_RUNNER_FLEET_STATUS_BYTES:
|
||||
return {"status": "unavailable", "reason": "runner_fleet_status_response_too_large"}
|
||||
except httpx.RequestError as exc:
|
||||
return {"status": "unreachable", "reason": redact_message(str(exc))}
|
||||
try:
|
||||
decoded = json.loads(bytes(body).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return {"status": "unavailable", "reason": "runner_fleet_status_invalid_response"}
|
||||
return decoded if isinstance(decoded, dict) else {"status": "unavailable", "reason": "runner_fleet_status_invalid_response"}
|
||||
Reference in new issue
Block a user