558 lines
24 KiB
Python
558 lines
24 KiB
Python
"""Task Plane HTTP API implementation (stdlib-only)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
import uuid
|
|
import urllib.parse
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import Any, Callable
|
|
|
|
from .delegation import DelegationEngine
|
|
from .errors import (
|
|
ConflictError,
|
|
ForbiddenError,
|
|
NotFoundError,
|
|
PayloadTooLargeError,
|
|
TaskPlaneHTTPError,
|
|
UnauthorizedError,
|
|
ValidationError,
|
|
)
|
|
from .evidence import EvidenceService
|
|
from .journal import TaskJournal
|
|
from .lease import LeaseManager
|
|
from .models import (
|
|
EvidenceEnvelope,
|
|
ResultEnvelope,
|
|
TaskPlan,
|
|
TaskRecord,
|
|
iso8601_now,
|
|
contains_secret_text,
|
|
validate_identifier,
|
|
)
|
|
from .policy import PolicyEngine
|
|
from .store import TaskPlaneStore
|
|
from .worker_registry import WorkerRegistry
|
|
|
|
|
|
def _json_payload(data: dict[str, Any], status: int = 200) -> tuple[int, bytes]:
|
|
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
return status, body
|
|
|
|
|
|
def _bounded_int(value: Any, name: str, minimum: int, maximum: int) -> int:
|
|
try:
|
|
number = int(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValidationError(f"invalid {name}") from exc
|
|
if not minimum <= number <= maximum:
|
|
raise ValidationError(f"invalid {name}")
|
|
return number
|
|
|
|
|
|
class TaskPlaneService:
|
|
def __init__(self, store: TaskPlaneStore, policy: PolicyEngine | None = None):
|
|
self.store = store
|
|
self.policy = policy or PolicyEngine(
|
|
execution_enabled=False,
|
|
identity_capabilities={
|
|
"anon": {"task.creator", "task.read", "task.audit"}
|
|
},
|
|
)
|
|
self.journal = TaskJournal(store)
|
|
self.lease = LeaseManager(store)
|
|
self.workers = WorkerRegistry(store)
|
|
self.evidence = EvidenceService(store)
|
|
self.delegation = DelegationEngine(store)
|
|
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
|
|
|
def create_task(self, body: dict[str, Any], idempotency_key: str | None = None) -> dict[str, Any]:
|
|
if body.get("parent_task_id"):
|
|
raise ForbiddenError("child tasks must use the delegate endpoint")
|
|
issuer = body.get("issuer", {})
|
|
actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
|
self.policy.assert_allowed("create", actor)
|
|
try:
|
|
plan = TaskPlan.from_dict(body)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValidationError("invalid task plan") from exc
|
|
task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key)
|
|
return task.to_dict()
|
|
|
|
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
|
if actor is not None:
|
|
self.policy.assert_allowed("read", actor)
|
|
return [task.to_dict(include_task=False) for task in self.store.list_tasks()]
|
|
|
|
def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]:
|
|
if actor is not None:
|
|
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
|
return self.store.get_task(task_id).to_dict()
|
|
|
|
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
|
|
if actor is not None:
|
|
self.policy.assert_allowed("read", actor)
|
|
return self.workers.get(worker_id).to_dict()
|
|
|
|
def list_workers(self, actor: str | None = None) -> list[dict[str, Any]]:
|
|
if actor is not None:
|
|
self.policy.assert_allowed("read", actor)
|
|
return [w.to_dict() for w in self.workers.list()]
|
|
|
|
def register_worker(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
actor = str(body.get("actor_identity") or body.get("identity") or "")
|
|
is_admin = actor in self.policy.admin_identities
|
|
try:
|
|
existing = self.store.get_worker(str(body.get("worker_id") or ""))
|
|
except NotFoundError:
|
|
existing = None
|
|
if existing is not None and str(body.get("identity") or "") != existing.identity:
|
|
raise ConflictError("worker identity takeover rejected")
|
|
self.policy.assert_readonly_worker_registration(actor, body)
|
|
self.policy.assert_allowed(
|
|
"worker.admin" if is_admin else "worker.register", actor
|
|
)
|
|
try:
|
|
worker = self.workers.register(
|
|
body,
|
|
actor_identity=actor,
|
|
is_admin=is_admin,
|
|
expected_revision=body.get("expected_revision"),
|
|
)
|
|
except (TypeError, ValueError, KeyError) as exc:
|
|
raise ValidationError("invalid worker registration") from exc
|
|
return worker.to_dict()
|
|
|
|
def worker_heartbeat(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
interval = payload.get("heartbeat_interval_seconds")
|
|
actor = str(payload.get("actor_identity") or "")
|
|
self.policy.assert_readonly_worker_binding(actor, worker_id)
|
|
self.policy.assert_allowed("worker.register", actor)
|
|
return self.workers.heartbeat(
|
|
worker_id,
|
|
heartbeat_interval_seconds=interval,
|
|
actor_identity=actor,
|
|
expected_revision=payload.get("expected_revision"),
|
|
).to_dict()
|
|
|
|
def disable_worker(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
actor = str(payload.get("actor_identity") or "")
|
|
self.policy.assert_allowed("worker.admin", actor)
|
|
return self.store.disable_worker(
|
|
worker_id,
|
|
actor_identity=actor,
|
|
expected_revision=int(payload.get("expected_revision")),
|
|
reason=str(payload.get("reason") or "disabled by administrator"),
|
|
).to_dict()
|
|
|
|
def restore_worker(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
actor = str(payload.get("actor_identity") or "")
|
|
self.policy.assert_allowed("worker.admin", actor)
|
|
return self.store.restore_worker(
|
|
worker_id,
|
|
actor_identity=actor,
|
|
expected_revision=int(payload.get("expected_revision")),
|
|
).to_dict()
|
|
|
|
def claim(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
self.policy.assert_allowed("claim", actor)
|
|
worker_id = body.get("worker_id")
|
|
if not worker_id:
|
|
raise ValidationError("worker_id required")
|
|
|
|
task = self.store.get_task(task_id)
|
|
self.policy.assert_readonly_worker_binding(actor, str(worker_id))
|
|
self.policy.assert_allowed("claim", actor, task=task)
|
|
lease_seconds = _bounded_int(
|
|
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
|
)
|
|
claim = self.lease.claim(
|
|
task_id=task_id,
|
|
worker_id=worker_id,
|
|
worker_identity=actor,
|
|
lease_seconds=lease_seconds,
|
|
expected_revision=task.revision,
|
|
operation_digest=task.task_plan.operation_digest(),
|
|
)
|
|
return {"claim_id": claim.claim_id, **claim.to_dict()}
|
|
|
|
def heartbeat(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
claim_id = body.get("claim_id")
|
|
if not claim_id:
|
|
raise ValidationError("claim_id required")
|
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
task = self.store.get_task(task_id)
|
|
claim = self.store.get_claim(claim_id)
|
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
|
self.policy.assert_allowed("heartbeat", actor, task=task)
|
|
if claim.worker_identity != actor:
|
|
raise ForbiddenError("claim identity mismatch")
|
|
lease_seconds = _bounded_int(
|
|
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
|
)
|
|
claim = self.lease.heartbeat(task_id, claim_id, lease_seconds)
|
|
return {"claim_id": claim_id, "lease_until": claim.lease_until}
|
|
|
|
def progress(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
claim_id = body.get("claim_id")
|
|
if not claim_id:
|
|
raise ValidationError("claim_id required")
|
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
progress = body.get("progress")
|
|
stage = body.get("stage", "RUNNING")
|
|
message = body.get("message")
|
|
if progress is None:
|
|
raise ValidationError("progress required")
|
|
task = self.store.get_task(task_id)
|
|
claim = self.store.get_claim(claim_id)
|
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
|
self.policy.assert_allowed("progress", actor, task=task)
|
|
if task.task_plan.data_classification == "credential-secret":
|
|
if message not in {None, ""} or contains_secret_text(stage):
|
|
raise ValidationError(
|
|
"credential-secret progress must contain metadata only"
|
|
)
|
|
lease_seconds = _bounded_int(
|
|
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
|
)
|
|
claim = self.store.record_progress(
|
|
task_id,
|
|
claim_id,
|
|
actor,
|
|
progress=progress,
|
|
stage=str(stage),
|
|
message=None if message is None else str(message),
|
|
lease_seconds=lease_seconds,
|
|
)
|
|
return {"claim_id": claim.claim_id, "status": claim.status, "progress": progress}
|
|
|
|
def complete(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
claim_id = body.get("claim_id")
|
|
if not claim_id:
|
|
raise ValidationError("claim_id required")
|
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
task = self.store.get_task(task_id)
|
|
|
|
status = body.get("status", "COMPLETED")
|
|
outputs = body.get("outputs", [])
|
|
evidences = body.get("evidence", [])
|
|
if not isinstance(outputs, list):
|
|
raise ValidationError("outputs must be list")
|
|
if not isinstance(evidences, list):
|
|
raise ValidationError("evidence must be list")
|
|
if any(not isinstance(ev, dict) for ev in evidences):
|
|
raise ValidationError("evidence items must be objects")
|
|
claim = self.store.get_claim(claim_id)
|
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
|
self.policy.assert_allowed("result", actor, task=task)
|
|
if claim.worker_identity != actor:
|
|
raise ForbiddenError("claim identity mismatch")
|
|
if task.task_plan.data_classification == "credential-secret":
|
|
allowed_result_keys = {
|
|
"kind",
|
|
"hash",
|
|
"sha256",
|
|
"count",
|
|
"status",
|
|
"error_code",
|
|
"signature",
|
|
"attestation_ref",
|
|
}
|
|
for entry in [*outputs, *evidences]:
|
|
if (
|
|
not isinstance(entry, dict)
|
|
or set(entry) - allowed_result_keys
|
|
or contains_secret_text(entry)
|
|
):
|
|
raise ValidationError(
|
|
"credential-secret result must contain hash/count/status only"
|
|
)
|
|
|
|
result = ResultEnvelope(
|
|
task_id=task_id,
|
|
claim_id=claim_id,
|
|
worker_identity=actor,
|
|
status=status,
|
|
outputs=outputs,
|
|
evidence=evidences,
|
|
started_at=str(body.get("started_at") or iso8601_now()),
|
|
completed_at=str(body.get("completed_at") or iso8601_now()),
|
|
result_digest=body.get("result_digest"),
|
|
signature=body.get("signature"),
|
|
attestation_ref=body.get("attestation_ref"),
|
|
operation_digest=claim.operation_digest,
|
|
)
|
|
evidence_decision = self.policy.evidence_verifier.verify(task, evidences)
|
|
if not evidence_decision.allowed:
|
|
raise ForbiddenError(evidence_decision.reason)
|
|
result_decision = self.policy.verify_result(result, task)
|
|
if not result_decision.allowed:
|
|
raise ForbiddenError(result_decision.reason)
|
|
result.result_digest = result.computed_digest()
|
|
previous = self.store._last_evidence_hash(task_id)
|
|
evidence_entries = []
|
|
for evidence in evidences:
|
|
envelope = EvidenceEnvelope.create(
|
|
task_id=task_id,
|
|
claim_id=claim_id,
|
|
kind=str(evidence.get("kind") or evidence.get("type") or "generic"),
|
|
worker_identity=actor,
|
|
payload=evidence,
|
|
previous_evidence_hash=previous,
|
|
signature=evidence.get("signature"),
|
|
attestation_ref=evidence.get("attestation_ref"),
|
|
)
|
|
evidence_entries.append(envelope)
|
|
previous = envelope.evidence_digest
|
|
self.store.complete_claim_with_evidence_and_event(
|
|
task_id,
|
|
claim_id,
|
|
status
|
|
if status
|
|
in {"COMPLETED", "FAILED_FINAL", "FAILED_RECOVERABLE", "CANCELLED"}
|
|
else "COMPLETED",
|
|
result.to_dict(),
|
|
evidence_entries,
|
|
)
|
|
return result.to_dict()
|
|
|
|
def fail(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
claim_id = body.get("claim_id")
|
|
if not claim_id:
|
|
raise ValidationError("claim_id required")
|
|
reason = body.get("reason", "failed")
|
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
self.policy.assert_allowed("fail", actor)
|
|
task = self.store.get_task(task_id)
|
|
self.policy.assert_allowed("fail", actor, task=task)
|
|
claim = self.store.get_claim(claim_id)
|
|
if claim.worker_identity != actor:
|
|
raise ForbiddenError("claim identity mismatch")
|
|
status = "FAILED_RECOVERABLE" if body.get("recoverable", False) else "FAILED_FINAL"
|
|
self.store.fail_claim_with_event(task_id, claim_id, status, str(reason))
|
|
return {"task_id": task_id, "status": status, "reason": reason}
|
|
|
|
def cancel(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
actor = body.get("actor_identity") or body.get("worker_identity") or "owner"
|
|
self.policy.assert_allowed("cancel", actor)
|
|
reason = body.get("reason", "cancelled")
|
|
task = self.store.get_task(task_id)
|
|
self.policy.assert_allowed("cancel", actor, task=task)
|
|
updated = self.store.cancel_task_with_event(
|
|
task_id, task.revision, reason=reason
|
|
)
|
|
return updated.to_dict()
|
|
|
|
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
actor = body.get("actor_identity") or "owner"
|
|
self.policy.assert_allowed("delegate", actor)
|
|
parent = self.store.get_task(task_id)
|
|
self.policy.assert_allowed("delegate", actor, task=parent)
|
|
if "task" in body:
|
|
try:
|
|
child_plan = TaskPlan.from_dict(body["task"])
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValidationError("invalid child task plan") from exc
|
|
else:
|
|
plan_body = dict(body)
|
|
plan_body.pop("actor_identity", None)
|
|
try:
|
|
child_plan = TaskPlan.from_dict(plan_body)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValidationError("invalid child task plan") from exc
|
|
child_plan.parent_task_id = task_id
|
|
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
|
return child.to_dict()
|
|
|
|
def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
|
if actor is not None:
|
|
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
|
self.store.get_task(task_id)
|
|
return self.store.list_events(task_id)
|
|
|
|
def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
|
if actor is not None:
|
|
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
|
self.store.get_task(task_id)
|
|
return self.evidence.list(task_id)
|
|
|
|
def get_children(self, task_id: str, actor: str | None = None) -> list[str]:
|
|
if actor is not None:
|
|
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
|
self.store.get_task(task_id)
|
|
return self.store.list_children(task_id)
|
|
|
|
|
|
class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
|
service: TaskPlaneService | None = None
|
|
authenticator: Callable[[str], str | None] | None = None
|
|
max_body_bytes = 1_000_000
|
|
|
|
def log_message(self, format: str, *args: Any) -> None:
|
|
# BaseHTTPRequestHandler logs the raw request target; suppress it so
|
|
# attacker-controlled paths or query strings cannot leak credentials.
|
|
return
|
|
|
|
def _json(self) -> tuple[str | None, dict[str, Any]]:
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
except ValueError as exc:
|
|
raise ValidationError("invalid content length") from exc
|
|
if length < 0:
|
|
raise ValidationError("invalid content length")
|
|
if length > self.max_body_bytes:
|
|
raise PayloadTooLargeError("payload exceeds limit")
|
|
raw = self.rfile.read(length) if length > 0 else b""
|
|
if not raw:
|
|
return None, {}
|
|
try:
|
|
decoded = raw.decode("utf-8")
|
|
payload = json.loads(decoded)
|
|
if not isinstance(payload, dict):
|
|
raise ValidationError("JSON payload must be an object")
|
|
return decoded, payload
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise ValidationError("invalid JSON payload") from exc
|
|
|
|
def _response(self, status: int, payload: Any) -> None:
|
|
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(encoded)))
|
|
self.end_headers()
|
|
self.wfile.write(encoded)
|
|
|
|
def _authenticated_identity(self) -> str:
|
|
authenticator = type(self).authenticator
|
|
if authenticator is None:
|
|
raise UnauthorizedError("authentication unavailable")
|
|
header = self.headers.get("Authorization", "")
|
|
if not header.startswith("Bearer "):
|
|
raise UnauthorizedError("bearer authentication required")
|
|
token = header[7:]
|
|
if not token or any(ord(ch) < 33 for ch in token):
|
|
raise UnauthorizedError("invalid bearer token")
|
|
identity = authenticator(token)
|
|
if not identity:
|
|
raise UnauthorizedError("invalid credentials")
|
|
return str(identity)
|
|
|
|
def _handle(self, handler):
|
|
try:
|
|
if self.service is None:
|
|
raise TaskPlaneHTTPError("service not configured")
|
|
result = handler()
|
|
if isinstance(result, tuple):
|
|
status, payload = result
|
|
else:
|
|
status, payload = 200, result
|
|
self._response(status, payload)
|
|
except TaskPlaneHTTPError as exc:
|
|
self._response(exc.status_code, exc.to_payload())
|
|
except Exception as exc: # defensive
|
|
if isinstance(exc, TaskPlaneHTTPError):
|
|
raise
|
|
self._response(
|
|
500,
|
|
{"error": "internal_error", "message": "internal server error"},
|
|
)
|
|
|
|
def do_GET(self): # noqa: N802
|
|
def route():
|
|
path = urllib.parse.urlparse(self.path).path
|
|
parts = [p for p in path.split("/") if p]
|
|
for part in parts[1:]:
|
|
if part not in {"events", "evidence", "children", "heartbeat"}:
|
|
try:
|
|
validate_identifier(part, "resource identifier")
|
|
except ValueError as exc:
|
|
raise ValidationError("invalid resource identifier") from exc
|
|
identity = self._authenticated_identity()
|
|
|
|
if len(parts) == 1 and parts[0] == "tasks":
|
|
return 200, self.service.list_tasks(identity)
|
|
if len(parts) == 2 and parts[0] == "tasks":
|
|
task_id = parts[1]
|
|
return 200, self.service.get_task(task_id, identity)
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "events":
|
|
return 200, self.service.get_events(parts[1], identity)
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "evidence":
|
|
return 200, self.service.get_evidence(parts[1], identity)
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "children":
|
|
return 200, self.service.get_children(parts[1], identity)
|
|
if len(parts) == 1 and parts[0] == "workers":
|
|
return 200, self.service.list_workers(identity)
|
|
if len(parts) == 2 and parts[0] == "workers":
|
|
return 200, self.service.get_worker(parts[1], identity)
|
|
raise NotFoundError("unknown endpoint")
|
|
|
|
self._handle(route)
|
|
|
|
def do_POST(self): # noqa: N802
|
|
def route():
|
|
body_text, body = self._json()
|
|
identity = self._authenticated_identity()
|
|
path = urllib.parse.urlparse(self.path).path
|
|
parts = [p for p in path.split("/") if p]
|
|
headers = {k.lower(): v for k, v in self.headers.items()}
|
|
|
|
if len(parts) == 1 and parts[0] == "tasks":
|
|
issuer = body.get("issuer")
|
|
if issuer and (
|
|
not isinstance(issuer, dict) or issuer.get("id") != identity
|
|
):
|
|
raise ForbiddenError("issuer identity mismatch")
|
|
body["issuer"] = {"type": "authenticated", "id": identity}
|
|
return 201, self.service.create_task(body or {}, headers.get("idempotency-key"))
|
|
if body.get("actor_identity") not in {None, identity}:
|
|
raise ForbiddenError("actor identity mismatch")
|
|
body["actor_identity"] = identity
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "claim":
|
|
return 200, self.service.claim(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "heartbeat":
|
|
return 200, self.service.heartbeat(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "progress":
|
|
return 200, self.service.progress(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "result":
|
|
return 200, self.service.complete(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "fail":
|
|
return 200, self.service.fail(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "cancel":
|
|
return 200, self.service.cancel(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "delegate":
|
|
return 200, self.service.delegate(parts[1], body or {})
|
|
|
|
if len(parts) == 2 and parts[0] == "workers" and parts[1] == "register":
|
|
if (
|
|
body.get("identity") != identity
|
|
and identity not in self.service.policy.admin_identities
|
|
):
|
|
raise ForbiddenError("worker identity mismatch")
|
|
return 201, self.service.register_worker(body or {})
|
|
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "heartbeat":
|
|
return 200, self.service.worker_heartbeat(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "disable":
|
|
return 200, self.service.disable_worker(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "restore":
|
|
return 200, self.service.restore_worker(parts[1], body or {})
|
|
raise NotFoundError("unknown endpoint")
|
|
|
|
self._handle(route)
|
|
|
|
|
|
def run_server(
|
|
host: str,
|
|
port: int,
|
|
service: TaskPlaneService,
|
|
*,
|
|
authenticator: Callable[[str], str | None] | None = None,
|
|
):
|
|
handler = TaskPlaneRequestHandler
|
|
handler.service = service
|
|
handler.authenticator = authenticator
|
|
server = ThreadingHTTPServer((host, port), handler)
|
|
server.serve_forever()
|