"""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 from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1 _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 forge_bootstrap_preflight( self, profile: ForgeBootstrapPreflightV1, *, secret: str ) -> dict[str, Any]: """Read fixed Forge source and host prerequisites through bootstrap HMAC.""" return self._request( "POST", "/v1/appops/forge/bootstrap/preflight", secret=secret, payload={"profile": profile.model_dump(mode="json")}, ) 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 initialize_managed_appdata(self, *, secret: str) -> dict[str, Any]: """Initialize only the fixed managed storage root through bootstrap HMAC.""" return self._request( "POST", "/v1/appops/managed-storage/initialize", secret=secret, payload={ "apiVersion": "itworx.managed-storage-initialize/v1", "target": "tower/deployment-controller", }, ) 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_specialize(self, *, reason: str) -> dict[str, Any]: """Request the fixed protected-source CI specialization migration.""" return self._request( "POST", "/v1/appops/runner-fleet/specialize", secret=settings.mcp_token or "", payload={"reason": reason[:1000]}, ) def runner_statistics_configure(self, *, reason: str) -> dict[str, Any]: """Provision the fixed independent read-only ProjectBrain capability.""" return self._request( "POST", "/v1/appops/runner-statistics/configure", secret=settings.mcp_token or "", payload={"reason": reason[:1000]}, ) def hub_migration_prepare_permissions(self, *, reason: str) -> dict[str, Any]: """Prepare only the fixed Hub backup and controller-state permissions.""" return self._request( "POST", "/v1/appops/hub-migration/prepare-permissions", 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. """ return self._runner_fleet_read("/v1/runner-fleet") def runner_fleet_statistics(self) -> dict[str, Any]: """Read the persisted profile projection without scheduling work.""" return self._runner_fleet_read("/v1/runner-fleet/statistics", timeout_seconds=min(self.timeout_seconds, 5)) def _runner_fleet_read(self, path: str, *, timeout_seconds: float | None = None) -> dict[str, Any]: if path not in {"/v1/runner-fleet", "/v1/runner-fleet/statistics"}: return {"status": "unavailable", "reason": "runner_fleet_path_invalid"} if not self.is_configured(): return {"status": "missing_config", "reason": self.configuration_error()} try: with httpx.Client( timeout=self.timeout_seconds if timeout_seconds is None else timeout_seconds, verify=self._verify_option(), follow_redirects=False, ) as client, client.stream( "GET", f"{self.base_url}{path}", 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"}