Files
Unraid-AppOps-Gateway-Public/backend/app/services/deployment_broker.py
T

587 lines
23 KiB
Python

"""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_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"}