feat: governed function runtime + billing drain
GCAP-SPEC-FUNCTION-DESCRIPTOR-0001 implementation. Mirrors connector runtime pattern exactly. FunctionPlugin — trigger_events, handle(), descriptor(), knative_manifest() FunctionRegistry — trigger_index for event-driven routing FunctionRuntime — invoke() + dispatch() with Chronicle lineage governed_function decorator — SDK surface for function authors BillingProcessor — GSAP_CR_RECEIVED → billable event with Chronicle CID EchoFunction — dev/test API: /functions/ catalogue, invoke, dispatch, manifest, health 8 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0987704264
commit
0c77943ceb
11 changed files with 697 additions and 1 deletions
|
|
@ -5,7 +5,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from gsap_broker.settings import settings
|
from gsap_broker.settings import settings
|
||||||
from gsap_broker.db import init_db
|
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()
|
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(elevate.router, prefix="/governance", tags=["Elevation"])
|
||||||
app.include_router(drivers.router, prefix="/governance", tags=["Drivers"])
|
app.include_router(drivers.router, prefix="/governance", tags=["Drivers"])
|
||||||
app.include_router(connectors.router, prefix="/connectors", tags=["Connectors"])
|
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"])
|
app.include_router(health.router, tags=["Health"])
|
||||||
|
|
|
||||||
4
gsap_broker/functions/__init__.py
Normal file
4
gsap_broker/functions/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from .base import FunctionPlugin, FunctionContext, FunctionResult
|
||||||
|
from .registry import FunctionRegistry
|
||||||
|
from .runtime import FunctionRuntime
|
||||||
|
from .decorator import governed_function
|
||||||
116
gsap_broker/functions/base.py
Normal file
116
gsap_broker/functions/base.py
Normal file
|
|
@ -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)},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
70
gsap_broker/functions/decorator.py
Normal file
70
gsap_broker/functions/decorator.py
Normal file
|
|
@ -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
|
||||||
2
gsap_broker/functions/examples/__init__.py
Normal file
2
gsap_broker/functions/examples/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
from .billing import BillingProcessor
|
||||||
|
from .echo import EchoFunction
|
||||||
62
gsap_broker/functions/examples/billing.py
Normal file
62
gsap_broker/functions/examples/billing.py
Normal file
|
|
@ -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
|
||||||
26
gsap_broker/functions/examples/echo.py
Normal file
26
gsap_broker/functions/examples/echo.py
Normal file
|
|
@ -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
|
||||||
37
gsap_broker/functions/registry.py
Normal file
37
gsap_broker/functions/registry.py
Normal file
|
|
@ -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]
|
||||||
106
gsap_broker/functions/runtime.py
Normal file
106
gsap_broker/functions/runtime.py
Normal file
|
|
@ -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
|
||||||
120
gsap_broker/routers/functions.py
Normal file
120
gsap_broker/routers/functions.py
Normal file
|
|
@ -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}
|
||||||
152
tests/test_functions.py
Normal file
152
tests/test_functions.py
Normal file
|
|
@ -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 "")
|
||||||
Loading…
Reference in a new issue