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 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"])
|
||||
|
|
|
|||
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