diff --git a/gsap_broker/app.py b/gsap_broker/app.py index a65b172..fbba6d4 100644 --- a/gsap_broker/app.py +++ b/gsap_broker/app.py @@ -5,7 +5,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from gsap_broker.settings import settings from gsap_broker.db import init_db -from gsap_broker.routers import authorize, complete, session, elevate, health, drivers, connectors +from gsap_broker.routers import authorize, complete, session, elevate, health, drivers, connectors, functions logger = structlog.get_logger() @@ -25,4 +25,5 @@ app.include_router(session.router, prefix="/governance", tags=["Session"]) app.include_router(elevate.router, prefix="/governance", tags=["Elevation"]) app.include_router(drivers.router, prefix="/governance", tags=["Drivers"]) app.include_router(connectors.router, prefix="/connectors", tags=["Connectors"]) +app.include_router(functions.router, prefix="/functions", tags=["Functions"]) app.include_router(health.router, tags=["Health"]) diff --git a/gsap_broker/functions/__init__.py b/gsap_broker/functions/__init__.py new file mode 100644 index 0000000..ead91c5 --- /dev/null +++ b/gsap_broker/functions/__init__.py @@ -0,0 +1,4 @@ +from .base import FunctionPlugin, FunctionContext, FunctionResult +from .registry import FunctionRegistry +from .runtime import FunctionRuntime +from .decorator import governed_function diff --git a/gsap_broker/functions/base.py b/gsap_broker/functions/base.py new file mode 100644 index 0000000..324e040 --- /dev/null +++ b/gsap_broker/functions/base.py @@ -0,0 +1,116 @@ +"""FunctionPlugin ABC per GCAP-SPEC-FUNCTION-DESCRIPTOR-0001 section 4.""" +from __future__ import annotations + +import hashlib +import json +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class FunctionContext: + trigger_event_kind: str = "" + trigger_event_cid: str = "" + chronicle_session_id: str = "" + gsap_context_id: str = "" + invocation_id: str = "" + pipeline_run_id: str = "" + + +@dataclass +class FunctionResult: + success: bool = False + data: Any = None + error: str | None = None + lineage_cid: str = "" + duration_ms: float = 0.0 + metadata: dict[str, Any] = field(default_factory=dict) + + def output_cid(self) -> str: + """Compute content-addressable hash of the output data.""" + canonical = json.dumps(self.data, sort_keys=True, separators=(",", ":"), default=str) + return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest() + + +class FunctionPlugin(ABC): + """Abstract base for governed serverless functions.""" + + function_id: str = "" + corpus_entry_cid: str = "" + capability_mask: int = 0 + trigger_events: list[str] = [] + accord_template: str = "" + gsap_required: bool = True + chronicle_enabled: bool = True + max_duration_seconds: int = 30 + display_name: str = "" + description: str = "" + version: str = "0.1.0" + + @abstractmethod + async def handle(self, event: dict[str, Any], context: FunctionContext) -> FunctionResult: + ... + + @abstractmethod + def health_check(self) -> bool: + ... + + def descriptor(self) -> dict[str, Any]: + """JSON-LD descriptor per GCAP-SPEC-FUNCTION-DESCRIPTOR-0001 §2.""" + return { + "@context": "https://schema.gsap.dev/function/v1", + "@type": "FunctionDescriptor", + "function_id": self.function_id, + "corpus_entry_cid": self.corpus_entry_cid, + "capability_mask": self.capability_mask, + "trigger_events": self.trigger_events, + "accord_template": self.accord_template, + "gsap_required": self.gsap_required, + "chronicle_enabled": self.chronicle_enabled, + "max_duration_seconds": self.max_duration_seconds, + "display_name": self.display_name, + "description": self.description, + "version": self.version, + } + + def knative_manifest(self, image: str, namespace: str = "default") -> dict[str, Any]: + """Generate Knative Service manifest.""" + return { + "apiVersion": "serving.knative.dev/v1", + "kind": "Service", + "metadata": { + "name": self.function_id, + "namespace": namespace, + "labels": { + "gcap.dev/function-id": self.function_id, + "gcap.dev/capability-mask": str(self.capability_mask), + }, + "annotations": { + "gcap.dev/corpus-entry-cid": self.corpus_entry_cid, + "gcap.dev/gsap-required": str(self.gsap_required).lower(), + }, + }, + "spec": { + "template": { + "metadata": { + "annotations": { + "autoscaling.knative.dev/minScale": "0", + "autoscaling.knative.dev/maxScale": "10", + }, + }, + "spec": { + "timeoutSeconds": self.max_duration_seconds, + "containers": [ + { + "image": image, + "env": [ + {"name": "GCAP_FUNCTION_ID", "value": self.function_id}, + {"name": "GCAP_CAPABILITY_MASK", "value": str(self.capability_mask)}, + ], + } + ], + }, + }, + }, + } diff --git a/gsap_broker/functions/decorator.py b/gsap_broker/functions/decorator.py new file mode 100644 index 0000000..e4a4dba --- /dev/null +++ b/gsap_broker/functions/decorator.py @@ -0,0 +1,70 @@ +"""governed_function decorator — wraps async functions as FunctionPlugins.""" +from __future__ import annotations + +import functools +from typing import Any, Callable, Awaitable + +from .base import FunctionContext, FunctionPlugin, FunctionResult + + +def governed_function( + function_id: str, + corpus_entry_cid: str, + capability_mask: int = 1, + trigger_events: list[str] | None = None, + accord_template: str = "", + gsap_required: bool = True, + chronicle_enabled: bool = True, + max_duration_seconds: int = 30, + display_name: str = "", + description: str = "", + version: str = "0.1.0", + registry: Any = None, +) -> Callable: + """Decorator that wraps an async function as a governed FunctionPlugin. + + Usage:: + + @governed_function( + function_id="my-func", + corpus_entry_cid="sha256:abc...", + ) + async def my_func(event, context): + return FunctionResult(success=True, data=event) + """ + + def decorator( + fn: Callable[[dict[str, Any], FunctionContext], Awaitable[FunctionResult]], + ) -> FunctionPlugin: + + class WrappedFunction(FunctionPlugin): + pass + + instance = WrappedFunction.__new__(WrappedFunction) + instance.function_id = function_id + instance.corpus_entry_cid = corpus_entry_cid + instance.capability_mask = capability_mask + instance.trigger_events = trigger_events or [] + instance.accord_template = accord_template + instance.gsap_required = gsap_required + instance.chronicle_enabled = chronicle_enabled + instance.max_duration_seconds = max_duration_seconds + instance.display_name = display_name or function_id + instance.description = description + instance.version = version + + async def handle(event: dict[str, Any], context: FunctionContext) -> FunctionResult: + return await fn(event, context) + + instance.handle = handle # type: ignore[assignment] + instance.health_check = lambda: True # type: ignore[assignment] + + # Auto-register if a registry is provided + if registry is not None: + registry.register(instance) + + # Preserve original function metadata + functools.update_wrapper(instance, fn) + return instance + + return decorator diff --git a/gsap_broker/functions/examples/__init__.py b/gsap_broker/functions/examples/__init__.py new file mode 100644 index 0000000..3e41012 --- /dev/null +++ b/gsap_broker/functions/examples/__init__.py @@ -0,0 +1,2 @@ +from .billing import BillingProcessor +from .echo import EchoFunction diff --git a/gsap_broker/functions/examples/billing.py b/gsap_broker/functions/examples/billing.py new file mode 100644 index 0000000..243a18b --- /dev/null +++ b/gsap_broker/functions/examples/billing.py @@ -0,0 +1,62 @@ +"""BillingProcessor — governed billing drain function per §5.""" +from __future__ import annotations + +from typing import Any + +from gsap_broker.functions.base import FunctionContext, FunctionPlugin, FunctionResult + + +RATE_CARD: dict[str, float] = { + "connector_invocation": 0.002, + "function_invocation": 0.001, + "chronicle_event": 0.0005, + "gsap_ac_issued": 0.003, + "gsap_cr_completed": 0.005, +} + + +class BillingProcessor(FunctionPlugin): + function_id = "billing-processor" + corpus_entry_cid = "sha256:" + "b" * 64 + capability_mask = 3 # MUTATE + trigger_events = ["GSAP_CR_RECEIVED"] + accord_template = "" + gsap_required = False + chronicle_enabled = True + max_duration_seconds = 10 + display_name = "Billing Processor" + description = "Governed billing drain — only bills completed outcomes with Chronicle CID reference." + version = "0.1.0" + + async def handle(self, event: dict[str, Any], context: FunctionContext) -> FunctionResult: + outcome = event.get("outcome", "") + if outcome != "completed": + return FunctionResult( + success=True, + data={"billed": False, "reason": f"Outcome '{outcome}' is not billable"}, + metadata={"function_id": self.function_id}, + ) + + event_type = event.get("event_type", "gsap_cr_completed") + rate = RATE_CARD.get(event_type, RATE_CARD["gsap_cr_completed"]) + quantity = event.get("quantity", 1) + amount = rate * quantity + + billing_record = { + "billed": True, + "event_type": event_type, + "rate": rate, + "quantity": quantity, + "amount": amount, + "chronicle_cid": context.trigger_event_cid or event.get("chronicle_cid", ""), + "invocation_id": context.invocation_id, + } + + return FunctionResult( + success=True, + data=billing_record, + metadata={"function_id": self.function_id}, + ) + + def health_check(self) -> bool: + return True diff --git a/gsap_broker/functions/examples/echo.py b/gsap_broker/functions/examples/echo.py new file mode 100644 index 0000000..6917e44 --- /dev/null +++ b/gsap_broker/functions/examples/echo.py @@ -0,0 +1,26 @@ +"""EchoFunction — minimal governed function example.""" +from __future__ import annotations + +from typing import Any + +from gsap_broker.functions.base import FunctionContext, FunctionPlugin, FunctionResult + + +class EchoFunction(FunctionPlugin): + function_id = "echo-function" + corpus_entry_cid = "sha256:" + "e" * 64 + capability_mask = 1 # READ + trigger_events = ["CONNECTOR_INVOKED"] + accord_template = "" + gsap_required = False + chronicle_enabled = True + max_duration_seconds = 5 + display_name = "Echo Function" + description = "Returns event data unchanged — used for integration testing." + version = "0.1.0" + + async def handle(self, event: dict[str, Any], context: FunctionContext) -> FunctionResult: + return FunctionResult(success=True, data=event) + + def health_check(self) -> bool: + return True diff --git a/gsap_broker/functions/registry.py b/gsap_broker/functions/registry.py new file mode 100644 index 0000000..174ed3a --- /dev/null +++ b/gsap_broker/functions/registry.py @@ -0,0 +1,37 @@ +"""FunctionRegistry — catalogue of governed serverless functions.""" +from __future__ import annotations + +from .base import FunctionPlugin + + +class FunctionRegistry: + def __init__(self) -> None: + self._functions: dict[str, FunctionPlugin] = {} + self._trigger_index: dict[str, list[str]] = {} + + def register(self, function: FunctionPlugin) -> None: + if not function.function_id: + raise ValueError("function_id must be non-empty") + if not function.corpus_entry_cid: + raise ValueError("corpus_entry_cid must be non-empty") + self._functions[function.function_id] = function + # Build trigger index + for event_kind in function.trigger_events: + self._trigger_index.setdefault(event_kind, []).append(function.function_id) + + def get(self, function_id: str) -> FunctionPlugin | None: + return self._functions.get(function_id) + + def catalogue(self) -> list[dict]: + return [f.descriptor() for f in self._functions.values()] + + def list_ids(self) -> list[str]: + return list(self._functions.keys()) + + def trigger_index(self) -> dict[str, list[str]]: + return dict(self._trigger_index) + + def by_trigger(self, event_kind: str) -> list[FunctionPlugin]: + """Return all functions registered for a given trigger event kind.""" + ids = self._trigger_index.get(event_kind, []) + return [self._functions[fid] for fid in ids if fid in self._functions] diff --git a/gsap_broker/functions/runtime.py b/gsap_broker/functions/runtime.py new file mode 100644 index 0000000..dfb2eb2 --- /dev/null +++ b/gsap_broker/functions/runtime.py @@ -0,0 +1,106 @@ +"""FunctionRuntime — governed invocation with Chronicle emission.""" +from __future__ import annotations + +import time +import uuid +from typing import Any + +from .base import FunctionContext, FunctionResult +from .registry import FunctionRegistry + + +class FunctionRuntime: + def __init__( + self, registry: FunctionRegistry, chronicle_client: Any = None + ) -> None: + self.registry = registry + self.chronicle_client = chronicle_client + + async def invoke( + self, + function_id: str, + event: dict[str, Any], + context: FunctionContext, + ) -> FunctionResult: + """Direct invocation of a function by ID.""" + function = self.registry.get(function_id) + if function is None: + return FunctionResult(success=False, error=f"Unknown function: {function_id}") + + if function.gsap_required and not context.gsap_context_id: + return FunctionResult(success=False, error="GSAP context required but not provided") + + if not context.invocation_id: + context.invocation_id = str(uuid.uuid4()) + + # Emit FUNCTION_INVOKED (0x2A01) before execution + await self._emit_chronicle( + "FUNCTION_INVOKED", + { + "event_code": "0x2A01", + "function_id": function_id, + "trigger_event_kind": context.trigger_event_kind, + "trigger_event_cid": context.trigger_event_cid, + "chronicle_session_id": context.chronicle_session_id, + "gsap_context_id": context.gsap_context_id, + "invocation_id": context.invocation_id, + "pipeline_run_id": context.pipeline_run_id, + }, + function, + ) + + start = time.monotonic() + result = await function.handle(event, context) + result.duration_ms = (time.monotonic() - start) * 1000 + + # Emit FUNCTION_COMPLETED (0x2A02) after execution + await self._emit_chronicle( + "FUNCTION_COMPLETED", + { + "event_code": "0x2A02", + "function_id": function_id, + "invocation_id": context.invocation_id, + "success": result.success, + "duration_ms": result.duration_ms, + "output_cid": result.output_cid() if result.data is not None else "", + "error": result.error, + }, + function, + ) + + return result + + async def dispatch( + self, + event_kind: str, + event_data: dict[str, Any], + trigger_cid: str = "", + ) -> list[dict[str, Any]]: + """Route an event to all functions registered for this event kind.""" + functions = self.registry.by_trigger(event_kind) + results = [] + for function in functions: + context = FunctionContext( + trigger_event_kind=event_kind, + trigger_event_cid=trigger_cid, + invocation_id=str(uuid.uuid4()), + ) + result = await self.invoke(function.function_id, event_data, context) + results.append({ + "function_id": function.function_id, + "success": result.success, + "data": result.data, + "error": result.error, + "duration_ms": result.duration_ms, + "lineage_cid": result.lineage_cid, + }) + return results + + async def _emit_chronicle( + self, event_type: str, payload: dict[str, Any], function: Any + ) -> None: + if function.chronicle_enabled and self.chronicle_client is not None: + try: + await self.chronicle_client.emit(event_type, payload) + except Exception: + pass # Chronicle failure must not break invocation diff --git a/gsap_broker/routers/functions.py b/gsap_broker/routers/functions.py new file mode 100644 index 0000000..974446e --- /dev/null +++ b/gsap_broker/routers/functions.py @@ -0,0 +1,120 @@ +"""Functions router — governed serverless function catalogue and invocation.""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from gsap_broker.functions.base import FunctionContext +from gsap_broker.functions.registry import FunctionRegistry +from gsap_broker.functions.runtime import FunctionRuntime +from gsap_broker.functions.examples.billing import BillingProcessor +from gsap_broker.functions.examples.echo import EchoFunction + +router = APIRouter() + +# Module-level registry and runtime +_registry = FunctionRegistry() +_runtime = FunctionRuntime(registry=_registry) + +# Register built-in functions +_registry.register(BillingProcessor()) +_registry.register(EchoFunction()) + + +class InvokeRequest(BaseModel): + event: dict[str, Any] = {} + trigger_event_kind: str = "" + trigger_event_cid: str = "" + chronicle_session_id: str = "" + gsap_context_id: str = "" + pipeline_run_id: str = "" + + +class DispatchRequest(BaseModel): + event_kind: str + event_data: dict[str, Any] = {} + trigger_cid: str = "" + + +class InvokeResponse(BaseModel): + success: bool + data: Any = None + error: str | None = None + lineage_cid: str = "" + function_id: str = "" + duration_ms: float = 0.0 + + +class DispatchResponse(BaseModel): + event_kind: str + dispatched_count: int + results: list[dict[str, Any]] + + +@router.get("/") +async def catalogue() -> dict: + return { + "functions": _registry.catalogue(), + "trigger_index": _registry.trigger_index(), + } + + +@router.get("/{function_id}/") +async def get_descriptor(function_id: str) -> dict: + function = _registry.get(function_id) + if function is None: + raise HTTPException(status_code=404, detail=f"Function not found: {function_id}") + return function.descriptor() + + +@router.get("/{function_id}/manifest/") +async def knative_manifest(function_id: str, image: str = "gcr.io/default/function:latest", namespace: str = "default") -> dict: + function = _registry.get(function_id) + if function is None: + raise HTTPException(status_code=404, detail=f"Function not found: {function_id}") + return function.knative_manifest(image=image, namespace=namespace) + + +@router.post("/{function_id}/invoke/") +async def invoke_function(function_id: str, body: InvokeRequest) -> InvokeResponse: + function = _registry.get(function_id) + if function is None: + raise HTTPException(status_code=404, detail=f"Function not found: {function_id}") + + ctx = FunctionContext( + trigger_event_kind=body.trigger_event_kind, + trigger_event_cid=body.trigger_event_cid, + chronicle_session_id=body.chronicle_session_id, + gsap_context_id=body.gsap_context_id, + pipeline_run_id=body.pipeline_run_id, + ) + result = await _runtime.invoke(function_id, body.event, ctx) + return InvokeResponse( + success=result.success, + data=result.data, + error=result.error, + lineage_cid=result.lineage_cid, + function_id=function_id, + duration_ms=result.duration_ms, + ) + + +@router.post("/dispatch/") +async def dispatch_event(body: DispatchRequest) -> DispatchResponse: + results = await _runtime.dispatch(body.event_kind, body.event_data, body.trigger_cid) + return DispatchResponse( + event_kind=body.event_kind, + dispatched_count=len(results), + results=results, + ) + + +@router.get("/{function_id}/health/") +async def health_check(function_id: str) -> dict: + function = _registry.get(function_id) + if function is None: + raise HTTPException(status_code=404, detail=f"Function not found: {function_id}") + healthy = function.health_check() + return {"function_id": function_id, "healthy": healthy} diff --git a/tests/test_functions.py b/tests/test_functions.py new file mode 100644 index 0000000..dac0b86 --- /dev/null +++ b/tests/test_functions.py @@ -0,0 +1,152 @@ +"""Tests for governed serverless function module.""" +import pytest + + +@pytest.mark.asyncio +async def test_function_catalogue(client): + resp = await client.get("/functions/") + assert resp.status_code == 200 + body = resp.json() + ids = [f["function_id"] for f in body["functions"]] + assert "billing-processor" in ids + assert "echo-function" in ids + assert "trigger_index" in body + + +@pytest.mark.asyncio +async def test_function_descriptor(client): + resp = await client.get("/functions/billing-processor/") + assert resp.status_code == 200 + desc = resp.json() + assert desc["function_id"] == "billing-processor" + assert "trigger_events" in desc + assert "GSAP_CR_RECEIVED" in desc["trigger_events"] + assert desc["corpus_entry_cid"].startswith("sha256:") + + +@pytest.mark.asyncio +async def test_knative_manifest(client): + resp = await client.get("/functions/billing-processor/manifest/?image=gcr.io/test/billing:v1") + assert resp.status_code == 200 + manifest = resp.json() + assert manifest["kind"] == "Service" + assert manifest["apiVersion"] == "serving.knative.dev/v1" + assert manifest["metadata"]["name"] == "billing-processor" + containers = manifest["spec"]["template"]["spec"]["containers"] + assert containers[0]["image"] == "gcr.io/test/billing:v1" + + +@pytest.mark.asyncio +async def test_invoke_echo(client): + resp = await client.post( + "/functions/echo-function/invoke/", + json={"event": {"msg": "hello", "value": 42}}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + assert body["data"]["msg"] == "hello" + assert body["data"]["value"] == 42 + assert body["function_id"] == "echo-function" + assert body["duration_ms"] >= 0 + + +@pytest.mark.asyncio +async def test_billing_completed(client): + resp = await client.post( + "/functions/billing-processor/invoke/", + json={ + "event": { + "outcome": "completed", + "event_type": "gsap_cr_completed", + "quantity": 2, + "chronicle_cid": "sha256:abc123", + }, + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + assert body["data"]["billed"] is True + assert body["data"]["amount"] == 0.01 # 0.005 * 2 + assert body["data"]["chronicle_cid"] == "sha256:abc123" + + +@pytest.mark.asyncio +async def test_billing_failed_not_billed(client): + resp = await client.post( + "/functions/billing-processor/invoke/", + json={ + "event": { + "outcome": "failed", + "event_type": "gsap_cr_completed", + }, + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + assert body["data"]["billed"] is False + + +@pytest.mark.asyncio +async def test_dispatch_routes_to_billing(client): + resp = await client.post( + "/functions/dispatch/", + json={ + "event_kind": "GSAP_CR_RECEIVED", + "event_data": { + "outcome": "completed", + "event_type": "gsap_cr_completed", + "quantity": 1, + }, + "trigger_cid": "sha256:trigger001", + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["event_kind"] == "GSAP_CR_RECEIVED" + assert body["dispatched_count"] >= 1 + # billing-processor should be in the results + billing_results = [r for r in body["results"] if r["function_id"] == "billing-processor"] + assert len(billing_results) == 1 + assert billing_results[0]["success"] is True + + +@pytest.mark.asyncio +async def test_gsap_required_without_ac(client): + """A gsap_required function invoked without gsap_context_id returns error.""" + # We need a function with gsap_required=True. Register one via the runtime. + # Use the billing-processor with gsap_required toggled, or invoke directly. + # Since billing-processor has gsap_required=False, we test via the runtime directly. + from gsap_broker.functions.base import FunctionContext, FunctionPlugin, FunctionResult + from gsap_broker.functions.registry import FunctionRegistry + from gsap_broker.functions.runtime import FunctionRuntime + from typing import Any + + class StrictFunction(FunctionPlugin): + function_id = "strict-func" + corpus_entry_cid = "sha256:" + "s" * 64 + capability_mask = 1 + trigger_events = [] + gsap_required = True + chronicle_enabled = False + max_duration_seconds = 5 + display_name = "Strict" + description = "Requires GSAP" + version = "0.1.0" + + async def handle(self, event: dict[str, Any], context: FunctionContext) -> FunctionResult: + return FunctionResult(success=True, data=event) + + def health_check(self) -> bool: + return True + + reg = FunctionRegistry() + reg.register(StrictFunction()) + rt = FunctionRuntime(registry=reg) + + ctx = FunctionContext() # No gsap_context_id + result = await rt.invoke("strict-func", {"test": True}, ctx) + assert result.success is False + assert "GSAP context required" in (result.error or "")