1063 lines
46 KiB
Python
1063 lines
46 KiB
Python
"""Client for the private, typed deployment broker surface.
|
|
|
|
The client sends a canonical DeploymentPlanV1 or a closed fixed-target
|
|
operation 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 base64
|
|
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
|
|
from ..schemas.forge_bootstrap_apply import ForgeBootstrapApplyV1
|
|
from ..schemas.forge_runtime_readiness import ForgeRuntimeReadinessV1
|
|
|
|
_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}$")
|
|
_FORGE_SECRET_DELIVERY_PATH = "/v1/appops/forge/bootstrap/secrets"
|
|
_MAX_FORGE_SECRET_DELIVERY_BODY_BYTES = 16 * 1024
|
|
_MAX_FORGE_SECRET_DELIVERY_RESPONSE_BYTES = 64 * 1024
|
|
_FORGE_RUNTIME_READINESS_PATH = "/v1/appops/forge/bootstrap/readiness/"
|
|
_MAX_FORGE_RUNTIME_READINESS_RESPONSE_BYTES = 32 * 1024
|
|
_FORGE_RUNTIME_READINESS_TOTAL_SECONDS = 35.0
|
|
_FORGE_RUNTIME_READINESS_READ_SECONDS = 20.0
|
|
|
|
|
|
def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
result: dict[str, Any] = {}
|
|
for key, value in pairs:
|
|
if key in result:
|
|
raise ValueError("duplicate_json_key")
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
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(),
|
|
request_timeout: httpx.Timeout | None = None,
|
|
) -> 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=request_timeout or 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}:
|
|
rejected = {"status": "unauthorized", "reason": "deployment_broker_authentication_failed"}
|
|
try:
|
|
auth_payload = response.json()
|
|
except ValueError:
|
|
auth_payload = None
|
|
if ("noMutation" in error_response_fields and isinstance(auth_payload, dict)
|
|
and isinstance(auth_payload.get("noMutation"), bool)):
|
|
rejected["noMutation"] = auth_payload["noMutation"]
|
|
return rejected
|
|
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 or field == "noMutation" and isinstance(value, bool):
|
|
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 hub_component_operation(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Only closed, signed component-promotion endpoints; never caller commands."""
|
|
if stage not in {"baseline", "preflight", "apply", "status"}:
|
|
raise ValueError("component promotion stage is invalid")
|
|
return self._request(
|
|
"POST", f"/v1/appops/hub-components/{stage}",
|
|
secret=settings.mcp_token or "", payload=payload,
|
|
error_response_fields=frozenset({"planId", "planDigest", "noMutation"}),
|
|
)
|
|
|
|
def hub_auto_apply(self, payload: dict[str, Any], *, preflight: bool = False) -> dict[str, Any]:
|
|
return self._request(
|
|
"POST", "/v1/appops/registered-targets/auto-apply" + ("/preflight" if preflight else ""),
|
|
secret=settings.mcp_token or "", payload=payload,
|
|
error_response_fields=frozenset({"noMutation"}),
|
|
)
|
|
|
|
def projectbrain_delivery_operation(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Use the fixed ProjectBrain retry gate; callers cannot select a repository or runtime."""
|
|
if stage not in {"preflight", "revalidate"} or not isinstance(payload, dict):
|
|
raise ValueError("ProjectBrain delivery stage is invalid")
|
|
if stage == "preflight":
|
|
if (set(payload) != {"revision"} or not isinstance(payload.get("revision"), str)
|
|
or not re.fullmatch(r"[0-9a-f]{40}", payload["revision"])):
|
|
raise ValueError("ProjectBrain preflight contract is invalid")
|
|
elif (set(payload) != {"revision", "expectedPreflightSha256", "idempotencyKey"}
|
|
or not isinstance(payload.get("revision"), str)
|
|
or not re.fullmatch(r"[0-9a-f]{40}", payload["revision"])
|
|
or not isinstance(payload.get("expectedPreflightSha256"), str)
|
|
or not re.fullmatch(r"[0-9a-f]{64}", payload["expectedPreflightSha256"])
|
|
or not isinstance(payload.get("idempotencyKey"), str)
|
|
or not re.fullmatch(r"[A-Za-z0-9._:-]{8,128}", payload["idempotencyKey"])):
|
|
raise ValueError("ProjectBrain revalidation contract is invalid")
|
|
return self._request(
|
|
"POST", f"/v1/appops/projectbrain-delivery/{stage}",
|
|
secret=settings.mcp_token or "", payload=payload,
|
|
error_response_fields=frozenset({"noMutation"}),
|
|
)
|
|
|
|
def hub_delivery_operation(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Use the fixed Hub signed-delivery gate without retrying uncertain requests."""
|
|
if stage not in {"preflight", "revalidate"} or not isinstance(payload, dict):
|
|
raise ValueError("Hub delivery stage is invalid")
|
|
if stage == "preflight":
|
|
if (set(payload) != {"revision"} or not isinstance(payload.get("revision"), str)
|
|
or not re.fullmatch(r"[0-9a-f]{40}", payload["revision"])):
|
|
raise ValueError("Hub delivery preflight contract is invalid")
|
|
elif (set(payload) != {"revision", "expectedPreflightSha256", "idempotencyKey"}
|
|
or not isinstance(payload.get("revision"), str)
|
|
or not re.fullmatch(r"[0-9a-f]{40}", payload["revision"])
|
|
or not isinstance(payload.get("expectedPreflightSha256"), str)
|
|
or not re.fullmatch(r"[0-9a-f]{64}", payload["expectedPreflightSha256"])
|
|
or not isinstance(payload.get("idempotencyKey"), str)
|
|
or not re.fullmatch(r"[A-Za-z0-9._:-]{8,128}", payload["idempotencyKey"])):
|
|
raise ValueError("Hub delivery revalidation contract is invalid")
|
|
result = self._request(
|
|
"POST", f"/v1/appops/hub-delivery/{stage}",
|
|
secret=settings.mcp_token or "", payload=payload,
|
|
error_response_fields=frozenset({"noMutation"}),
|
|
)
|
|
if stage == "revalidate" and (
|
|
result.get("status") == "unreachable" or type(result.get("noMutation")) is not bool
|
|
):
|
|
return {
|
|
"status": "unknown",
|
|
"reason": "hub_delivery_outcome_unknown",
|
|
"noMutation": False,
|
|
}
|
|
if stage == "preflight" and result.get("status") == "unreachable":
|
|
return {
|
|
"status": "unavailable",
|
|
"reason": "hub_delivery_preflight_unavailable",
|
|
"noMutation": True,
|
|
}
|
|
if stage == "preflight" and type(result.get("noMutation")) is not bool:
|
|
return {
|
|
"status": "unavailable",
|
|
"reason": "hub_delivery_preflight_response_invalid",
|
|
"noMutation": True,
|
|
}
|
|
if stage == "preflight" and result.get("noMutation") is not True:
|
|
return {
|
|
"status": "unknown",
|
|
"reason": "hub_delivery_preflight_mutation_unexpected",
|
|
"noMutation": False,
|
|
}
|
|
return result
|
|
|
|
def registered_legacy_operation(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Signed fixed-stage metadata adoption/recovery for existing registered targets."""
|
|
if stage not in {"observe", "baseline", "preflight", "adopt", "status", "rollback", "delivery"}:
|
|
raise ValueError("registered legacy stage is invalid")
|
|
if stage == "observe" and (not isinstance(payload, dict) or set(payload) != {"repository"}):
|
|
raise ValueError("registered observation contract is invalid")
|
|
if stage == "baseline":
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("registered capture contract is invalid")
|
|
base = {"repository", "requestId"}
|
|
bindings = {"expectedSourceFingerprint", "expectedRuntimeFingerprint", "expectedStateSha256", "expectedContainerIds"}
|
|
capture = payload.get("captureRuntimeEnvironment", False)
|
|
if (type(capture) is not bool or (capture is False and
|
|
set(payload) not in (base, base | {"captureRuntimeEnvironment"}))):
|
|
raise ValueError("registered capture contract is invalid")
|
|
if capture is True:
|
|
if (set(payload) != base | bindings | {"captureRuntimeEnvironment"}
|
|
or any(not isinstance(payload.get(field), str) or not re.fullmatch(r"[0-9a-f]{64}", payload[field])
|
|
for field in bindings - {"expectedContainerIds"})
|
|
or not isinstance(payload.get("expectedContainerIds"), dict)
|
|
or not 1 <= len(payload["expectedContainerIds"]) <= 64
|
|
or any(not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", name)
|
|
or not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value)
|
|
for name, value in payload["expectedContainerIds"].items())):
|
|
raise ValueError("registered capture contract is invalid")
|
|
return self._request(
|
|
"POST", f"/v1/appops/registered-legacy/{stage}",
|
|
secret=settings.mcp_token or "", payload=payload,
|
|
error_response_fields=frozenset({"noMutation"}),
|
|
)
|
|
|
|
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 forge_bootstrap_apply(
|
|
self,
|
|
profile: ForgeBootstrapApplyV1,
|
|
*,
|
|
idempotency_key: str,
|
|
secret: str,
|
|
) -> dict[str, Any]:
|
|
"""Submit only the fixed, preflight-bound Forge first-install profile."""
|
|
result = self._request(
|
|
"POST",
|
|
"/v1/appops/forge/bootstrap/apply",
|
|
secret=secret,
|
|
payload={
|
|
"profile": profile.model_dump(mode="json"),
|
|
"idempotencyKey": idempotency_key,
|
|
},
|
|
error_response_fields=frozenset({
|
|
"apiVersion", "request", "repository", "revision", "operation",
|
|
"workspaceImageDigest", "frontdoorImageDigest", "noMutation",
|
|
}),
|
|
)
|
|
if result.get("status") == "preflight_changed":
|
|
result.pop("reason", None)
|
|
return result
|
|
|
|
def forge_secret_delivery(
|
|
self,
|
|
profile: ForgeBootstrapPreflightV1,
|
|
*,
|
|
config_bytes: bytes,
|
|
oidc_client_secret: bytes,
|
|
authentik_read_token: bytes,
|
|
secret: str,
|
|
) -> dict[str, Any]:
|
|
"""Send the single fixed Forge credential bundle request.
|
|
|
|
Any response after transmission is treated as an unknown outcome unless
|
|
it is a bounded, strict JSON object. Caller-side validation still owns
|
|
the complete success contract and never echoes this internal response.
|
|
"""
|
|
configuration_error = self.configuration_error()
|
|
if configuration_error is not None:
|
|
return {
|
|
"status": "missing_config",
|
|
"reason": "forge_secret_delivery_broker_configuration_invalid",
|
|
}
|
|
if (
|
|
not isinstance(secret, str)
|
|
or len(secret) < 32
|
|
or not secret.isascii()
|
|
or not secret.isprintable()
|
|
or not isinstance(config_bytes, bytes)
|
|
or not 1 <= len(config_bytes) <= 8192
|
|
or not isinstance(oidc_client_secret, bytes)
|
|
or not 32 <= len(oidc_client_secret) <= 255
|
|
or any(byte < 0x20 or byte > 0x7E for byte in oidc_client_secret)
|
|
or not isinstance(authentik_read_token, bytes)
|
|
or not 32 <= len(authentik_read_token) <= 256
|
|
or any(byte < 0x21 or byte > 0x7E for byte in authentik_read_token)
|
|
):
|
|
return {"status": "missing_secret", "reason": "forge_secret_delivery_inputs_invalid"}
|
|
|
|
payload = {
|
|
"apiVersion": "itworx.forge-secret-delivery/v1",
|
|
"target": "itworx-forge",
|
|
"profile": profile.model_dump(mode="json"),
|
|
"configBase64": base64.b64encode(config_bytes).decode("ascii"),
|
|
"oidcClientSecretBase64": base64.b64encode(oidc_client_secret).decode("ascii"),
|
|
"authentikReadTokenBase64": base64.b64encode(authentik_read_token).decode("ascii"),
|
|
}
|
|
body = self._canonical_payload(payload)
|
|
payload.clear()
|
|
if len(body) > _MAX_FORGE_SECRET_DELIVERY_BODY_BYTES:
|
|
return {"status": "missing_secret", "reason": "forge_secret_delivery_request_too_large"}
|
|
|
|
timestamp = str(int(time.time()))
|
|
nonce = uuid.uuid4().hex
|
|
path = _FORGE_SECRET_DELIVERY_PATH
|
|
headers = {
|
|
"Accept": "application/json",
|
|
"Accept-Encoding": "identity",
|
|
"Content-Type": "application/json",
|
|
"X-ITWorx-Deployment-Timestamp": timestamp,
|
|
"X-ITWorx-Deployment-Nonce": nonce,
|
|
"X-ITWorx-Deployment-Signature": self._signature(
|
|
secret, "POST", path, timestamp, nonce, body
|
|
),
|
|
}
|
|
response_body = bytearray()
|
|
try:
|
|
with httpx.Client(
|
|
timeout=self.timeout_seconds,
|
|
verify=self._verify_option(),
|
|
follow_redirects=False,
|
|
) as client, client.stream(
|
|
"POST",
|
|
f"{self.base_url}{path}",
|
|
content=body,
|
|
headers=headers,
|
|
) as response:
|
|
if response.status_code != 200:
|
|
return {
|
|
"status": "outcome_unknown",
|
|
"reason": "forge_secret_delivery_controller_rejected",
|
|
}
|
|
content_length = response.headers.get("content-length")
|
|
if content_length is not None and (
|
|
not content_length.isascii()
|
|
or not content_length.isdecimal()
|
|
or len(content_length) > 10
|
|
or int(content_length) > _MAX_FORGE_SECRET_DELIVERY_RESPONSE_BYTES
|
|
):
|
|
return {
|
|
"status": "outcome_unknown",
|
|
"reason": "forge_secret_delivery_response_invalid",
|
|
}
|
|
for chunk in response.iter_bytes(chunk_size=16 * 1024):
|
|
response_body.extend(chunk)
|
|
if len(response_body) > _MAX_FORGE_SECRET_DELIVERY_RESPONSE_BYTES:
|
|
response_body.clear()
|
|
return {
|
|
"status": "outcome_unknown",
|
|
"reason": "forge_secret_delivery_response_invalid",
|
|
}
|
|
except Exception: # noqa: BLE001 - exception text can contain held credentials.
|
|
response_body.clear()
|
|
return {
|
|
"status": "outcome_unknown",
|
|
"reason": "forge_secret_delivery_transport_unknown",
|
|
}
|
|
|
|
try:
|
|
decoded = json.loads(
|
|
bytes(response_body).decode("utf-8"),
|
|
object_pairs_hook=_unique_json_object,
|
|
)
|
|
except (UnicodeError, ValueError, TypeError, RecursionError):
|
|
response_body.clear()
|
|
return {
|
|
"status": "outcome_unknown",
|
|
"reason": "forge_secret_delivery_response_invalid",
|
|
}
|
|
response_body.clear()
|
|
if not isinstance(decoded, dict):
|
|
return {
|
|
"status": "outcome_unknown",
|
|
"reason": "forge_secret_delivery_response_invalid",
|
|
}
|
|
return {"status": "response", "response": decoded}
|
|
|
|
def forge_bootstrap_resume(self, request_id: str, *, secret: str) -> dict[str, Any]:
|
|
"""Resume only one recorded pre-migration credentials blocker."""
|
|
if not _REQUEST_ID.fullmatch(request_id):
|
|
return {"status": "invalid_request_id"}
|
|
result = self._request(
|
|
"POST",
|
|
f"/v1/appops/forge/bootstrap/resume/{request_id}",
|
|
secret=secret,
|
|
payload={},
|
|
error_response_fields=frozenset({
|
|
"apiVersion", "request", "repository", "revision", "operation",
|
|
"workspaceImageDigest", "frontdoorImageDigest", "noMutation",
|
|
}),
|
|
)
|
|
if result.get("status") == "preflight_changed":
|
|
result.pop("reason", None)
|
|
return result
|
|
|
|
def forge_bootstrap_status(self, request_id: str, *, secret: str) -> dict[str, Any]:
|
|
"""Read one fixed Forge first-install request; never submits mutations."""
|
|
if not _REQUEST_ID.fullmatch(request_id):
|
|
return {"status": "invalid_request_id"}
|
|
return self._request(
|
|
"GET",
|
|
f"/v1/appops/forge/bootstrap/status/{request_id}",
|
|
secret=secret,
|
|
)
|
|
|
|
def forge_bootstrap_readiness(
|
|
self, request_id: str, *, secret: str
|
|
) -> dict[str, Any]:
|
|
"""Fetch one fresh fixed-profile runtime snapshot with no receipt fallback."""
|
|
if not _REQUEST_ID.fullmatch(request_id):
|
|
return {"status": "unavailable"}
|
|
if (
|
|
not self.is_configured()
|
|
or not isinstance(secret, str)
|
|
or len(secret) < 32
|
|
or not secret.isascii()
|
|
or not secret.isprintable()
|
|
):
|
|
return {"status": "unavailable"}
|
|
|
|
timestamp = str(int(time.time()))
|
|
nonce = uuid.uuid4().hex
|
|
path = f"{_FORGE_RUNTIME_READINESS_PATH}{request_id}"
|
|
headers = {
|
|
"Accept": "application/json",
|
|
"Accept-Encoding": "identity",
|
|
"Cache-Control": "no-store",
|
|
"Pragma": "no-cache",
|
|
"X-ITWorx-Deployment-Timestamp": timestamp,
|
|
"X-ITWorx-Deployment-Nonce": nonce,
|
|
"X-ITWorx-Deployment-Signature": self._signature(
|
|
secret, "GET", path, timestamp, nonce, b""
|
|
),
|
|
}
|
|
response_body = bytearray()
|
|
deadline = time.monotonic() + _FORGE_RUNTIME_READINESS_TOTAL_SECONDS
|
|
client_timeout = httpx.Timeout(
|
|
connect=min(self.timeout_seconds, 5.0),
|
|
read=_FORGE_RUNTIME_READINESS_READ_SECONDS,
|
|
write=min(self.timeout_seconds, 5.0),
|
|
pool=min(self.timeout_seconds, 5.0),
|
|
)
|
|
try:
|
|
with httpx.Client(
|
|
timeout=client_timeout,
|
|
verify=self._verify_option(),
|
|
follow_redirects=False,
|
|
) as client, client.stream(
|
|
"GET",
|
|
f"{self.base_url}{path}",
|
|
content=b"",
|
|
headers=headers,
|
|
) as response:
|
|
if response.status_code != 200:
|
|
return {"status": "unavailable"}
|
|
content_lengths = response.headers.get_list("content-length")
|
|
if len(content_lengths) > 1:
|
|
return {"status": "unavailable"}
|
|
content_length = content_lengths[0] if content_lengths else None
|
|
if content_length is not None and (
|
|
not content_length.isascii()
|
|
or not content_length.isdecimal()
|
|
or len(content_length) > 10
|
|
or int(content_length) > _MAX_FORGE_RUNTIME_READINESS_RESPONSE_BYTES
|
|
):
|
|
return {"status": "unavailable"}
|
|
content_encoding = response.headers.get("content-encoding", "identity").lower()
|
|
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
if content_encoding != "identity" or content_type != "application/json":
|
|
return {"status": "unavailable"}
|
|
for chunk in response.iter_raw():
|
|
if time.monotonic() > deadline:
|
|
response_body.clear()
|
|
return {"status": "unavailable"}
|
|
response_body.extend(chunk)
|
|
if len(response_body) > _MAX_FORGE_RUNTIME_READINESS_RESPONSE_BYTES:
|
|
response_body.clear()
|
|
return {"status": "unavailable"}
|
|
except Exception: # noqa: BLE001 - transport diagnostics may contain held credentials.
|
|
response_body.clear()
|
|
return {"status": "unavailable"}
|
|
if time.monotonic() > deadline:
|
|
response_body.clear()
|
|
return {"status": "unavailable"}
|
|
|
|
try:
|
|
decoded = json.loads(
|
|
bytes(response_body).decode("utf-8", errors="strict"),
|
|
object_pairs_hook=_unique_json_object,
|
|
)
|
|
validated = ForgeRuntimeReadinessV1.model_validate(decoded)
|
|
except (UnicodeError, ValueError, TypeError, RecursionError):
|
|
response_body.clear()
|
|
return {"status": "unavailable"}
|
|
response_body.clear()
|
|
now = int(time.time())
|
|
if (
|
|
validated.request != request_id
|
|
or validated.nonce != nonce
|
|
or validated.observed_at < now - 30
|
|
or validated.observed_at > now + 5
|
|
):
|
|
return {"status": "unavailable"}
|
|
return validated.model_dump(mode="json", by_alias=True)
|
|
|
|
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."""
|
|
result = self._request(
|
|
"POST", "/v1/appops/runner-fleet/specialize",
|
|
secret=settings.mcp_token or "", payload={"reason": reason[:1000]},
|
|
request_timeout=httpx.Timeout(connect=5, write=5, pool=5, read=350),
|
|
)
|
|
# The controller can outlive a lost HTTP response. Never translate a
|
|
# transport failure into proof of no host effects or permission to retry.
|
|
if result.get("status") == "unreachable":
|
|
return {
|
|
"status": "unknown", "reason": "runner_specialization_outcome_unknown",
|
|
"noMutation": False, "effectsPerformed": None,
|
|
}
|
|
return result
|
|
|
|
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 runner_acceptance(self, *, reason: str, preflight: bool) -> dict[str, Any]:
|
|
result = self._request(
|
|
"POST", "/v1/appops/runner-acceptance/preflight" if preflight else "/v1/appops/runner-acceptance/quiesce",
|
|
secret=settings.mcp_token or "", payload={"reason": reason[:1000]},
|
|
request_timeout=httpx.Timeout(connect=5, write=5, pool=5, read=350),
|
|
)
|
|
if not preflight and result.get("status") in {"unreachable", "error", "rejected"}:
|
|
return {"status": "unknown", "noMutation": False, "effectsPerformed": None,
|
|
"blockers": ["maintenance_outcome_unknown"]}
|
|
return result
|
|
|
|
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"}
|