agentplane-runtime 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentplane_runtime-0.0.1/.gitignore +34 -0
- agentplane_runtime-0.0.1/PKG-INFO +48 -0
- agentplane_runtime-0.0.1/README.md +21 -0
- agentplane_runtime-0.0.1/pyproject.toml +40 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/__init__.py +8 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/api.py +232 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/app.py +85 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/auth.py +137 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/db.py +129 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/definitions.py +280 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/engine.py +407 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/llm.py +121 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/registration.py +145 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/resources.py +237 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/secrets.py +45 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/serving.py +383 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/settings.py +31 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/tracing.py +52 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/validation.py +91 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/vector.py +149 -0
- agentplane_runtime-0.0.1/tests/conftest.py +93 -0
- agentplane_runtime-0.0.1/tests/test_definitions_api.py +212 -0
- agentplane_runtime-0.0.1/tests/test_engine.py +122 -0
- agentplane_runtime-0.0.1/tests/test_engine_router.py +81 -0
- agentplane_runtime-0.0.1/tests/test_resources_api.py +59 -0
- agentplane_runtime-0.0.1/tests/test_secrets.py +33 -0
- agentplane_runtime-0.0.1/tests/test_serving.py +185 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# internal working documents — not part of the public repo
|
|
2
|
+
CLAUDE.md
|
|
3
|
+
SPEC.md
|
|
4
|
+
.claude/
|
|
5
|
+
|
|
6
|
+
# python
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.venv/
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
|
|
14
|
+
# tooling caches
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.coverage
|
|
19
|
+
htmlcov/
|
|
20
|
+
|
|
21
|
+
# local databases and state
|
|
22
|
+
*.db
|
|
23
|
+
registry.db
|
|
24
|
+
runtime.db
|
|
25
|
+
|
|
26
|
+
# environments / secrets
|
|
27
|
+
.env
|
|
28
|
+
.env.*
|
|
29
|
+
|
|
30
|
+
# editors / OS
|
|
31
|
+
.vscode/
|
|
32
|
+
.idea/
|
|
33
|
+
.DS_Store
|
|
34
|
+
Thumbs.db
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentplane-runtime
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: agentplane runtime: definitions, resources, flow execution, A2A + MCP serving
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: a2a-sdk[http-server]<2,>=1.1
|
|
8
|
+
Requires-Dist: agentplane-core<0.1.0,>=0.0.1
|
|
9
|
+
Requires-Dist: agentplane-sdk<0.1.0,>=0.0.1
|
|
10
|
+
Requires-Dist: aiosqlite>=0.20
|
|
11
|
+
Requires-Dist: cryptography>=43
|
|
12
|
+
Requires-Dist: fastapi>=0.115
|
|
13
|
+
Requires-Dist: fastmcp==3.4.4
|
|
14
|
+
Requires-Dist: httpx>=0.27
|
|
15
|
+
Requires-Dist: langgraph<2,>=1.0
|
|
16
|
+
Requires-Dist: opentelemetry-api>=1.27
|
|
17
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27
|
|
18
|
+
Requires-Dist: opentelemetry-sdk>=1.27
|
|
19
|
+
Requires-Dist: pydantic-settings>=2.4
|
|
20
|
+
Requires-Dist: pyjwt[crypto]>=2.9
|
|
21
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.30
|
|
22
|
+
Requires-Dist: uvicorn>=0.30
|
|
23
|
+
Provides-Extra: postgres
|
|
24
|
+
Requires-Dist: asyncpg>=0.29; extra == 'postgres'
|
|
25
|
+
Requires-Dist: pgvector>=0.3; extra == 'postgres'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# agentplane-runtime
|
|
29
|
+
|
|
30
|
+
Owns flow definitions and resources; executes flows (LangGraph); serves each
|
|
31
|
+
deployed flow as an **A2A agent** (`/a2a/{name}`, a2a-sdk, A2A v1.0) or an
|
|
32
|
+
**MCP server** (`/mcp/{name}`, FastMCP streamable HTTP); self-registers with
|
|
33
|
+
the agentplane registry.
|
|
34
|
+
|
|
35
|
+
Required configuration (env prefix `AGENTPLANE_RUNTIME_`):
|
|
36
|
+
|
|
37
|
+
| Variable | Purpose |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `PUBLIC_BASE_URL` | externally reachable base (a gateway route) |
|
|
40
|
+
| `REGISTRY_URL` | where to self-register (empty disables registration) |
|
|
41
|
+
| `SECRET_KEY` | Fernet key for resource credentials |
|
|
42
|
+
| `LLM_BASE_URL` | gateway's OpenAI-compatible endpoint (resource default) |
|
|
43
|
+
|
|
44
|
+
Run it:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
uvicorn --factory agentplane_runtime.app:create_app --port 8000
|
|
48
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# agentplane-runtime
|
|
2
|
+
|
|
3
|
+
Owns flow definitions and resources; executes flows (LangGraph); serves each
|
|
4
|
+
deployed flow as an **A2A agent** (`/a2a/{name}`, a2a-sdk, A2A v1.0) or an
|
|
5
|
+
**MCP server** (`/mcp/{name}`, FastMCP streamable HTTP); self-registers with
|
|
6
|
+
the agentplane registry.
|
|
7
|
+
|
|
8
|
+
Required configuration (env prefix `AGENTPLANE_RUNTIME_`):
|
|
9
|
+
|
|
10
|
+
| Variable | Purpose |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `PUBLIC_BASE_URL` | externally reachable base (a gateway route) |
|
|
13
|
+
| `REGISTRY_URL` | where to self-register (empty disables registration) |
|
|
14
|
+
| `SECRET_KEY` | Fernet key for resource credentials |
|
|
15
|
+
| `LLM_BASE_URL` | gateway's OpenAI-compatible endpoint (resource default) |
|
|
16
|
+
|
|
17
|
+
Run it:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
uvicorn --factory agentplane_runtime.app:create_app --port 8000
|
|
21
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agentplane-runtime"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "agentplane runtime: definitions, resources, flow execution, A2A + MCP serving"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"agentplane-core>=0.0.1,<0.1.0",
|
|
10
|
+
"agentplane-sdk>=0.0.1,<0.1.0",
|
|
11
|
+
"a2a-sdk[http-server]>=1.1,<2",
|
|
12
|
+
"fastapi>=0.115",
|
|
13
|
+
"sqlalchemy[asyncio]>=2.0.30",
|
|
14
|
+
"aiosqlite>=0.20",
|
|
15
|
+
"httpx>=0.27",
|
|
16
|
+
"pydantic-settings>=2.4",
|
|
17
|
+
"pyjwt[crypto]>=2.9",
|
|
18
|
+
"cryptography>=43",
|
|
19
|
+
"langgraph>=1.0,<2",
|
|
20
|
+
# exact pin — fastmcp ships breaking changes in minor releases (CLAUDE.md)
|
|
21
|
+
"fastmcp==3.4.4",
|
|
22
|
+
"opentelemetry-api>=1.27",
|
|
23
|
+
"opentelemetry-sdk>=1.27",
|
|
24
|
+
"opentelemetry-exporter-otlp-proto-http>=1.27",
|
|
25
|
+
"uvicorn>=0.30",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
postgres = ["asyncpg>=0.29", "pgvector>=0.3"]
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/agentplane_runtime"]
|
|
37
|
+
|
|
38
|
+
[tool.uv.sources]
|
|
39
|
+
agentplane-core = { workspace = true }
|
|
40
|
+
agentplane-sdk = { workspace = true }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""agentplane-runtime: definitions, resources, flow execution, A2A + MCP serving."""
|
|
2
|
+
|
|
3
|
+
from agentplane_runtime.app import create_app
|
|
4
|
+
from agentplane_runtime.settings import RUNTIME_VERSION, RuntimeSettings
|
|
5
|
+
|
|
6
|
+
__version__ = RUNTIME_VERSION
|
|
7
|
+
|
|
8
|
+
__all__ = ["RUNTIME_VERSION", "RuntimeSettings", "create_app"]
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Runtime REST API (SPEC §6.1/§6.3), prefix ``/api/v1``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Annotated, Literal
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
|
|
11
|
+
from agentplane_core import (
|
|
12
|
+
DefinitionInfo,
|
|
13
|
+
DeploymentInfo,
|
|
14
|
+
FlowDefinition,
|
|
15
|
+
JsonObject,
|
|
16
|
+
Resource,
|
|
17
|
+
ValidationResult,
|
|
18
|
+
)
|
|
19
|
+
from agentplane_runtime.auth import Principal
|
|
20
|
+
from agentplane_runtime.definitions import (
|
|
21
|
+
DefinitionConflictError,
|
|
22
|
+
DefinitionInvalidError,
|
|
23
|
+
DefinitionNotFoundError,
|
|
24
|
+
DefinitionService,
|
|
25
|
+
DefinitionStateError,
|
|
26
|
+
)
|
|
27
|
+
from agentplane_runtime.resources import (
|
|
28
|
+
ResourceConflictError,
|
|
29
|
+
ResourceNotFoundError,
|
|
30
|
+
ResourceService,
|
|
31
|
+
ResourceValidationError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RuntimeState:
|
|
37
|
+
definitions: DefinitionService
|
|
38
|
+
resources: ResourceService
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _state(request: Request) -> RuntimeState:
|
|
42
|
+
state: RuntimeState = request.app.state.runtime
|
|
43
|
+
return state
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _principal(request: Request) -> Principal:
|
|
47
|
+
principal: Principal = await request.app.state.authenticator.authenticate(request)
|
|
48
|
+
return principal
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
State = Annotated[RuntimeState, Depends(_state)]
|
|
52
|
+
Caller = Annotated[Principal, Depends(_principal)]
|
|
53
|
+
|
|
54
|
+
router = APIRouter(prefix="/api/v1")
|
|
55
|
+
health_router = APIRouter()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validation_response(result: ValidationResult) -> JSONResponse:
|
|
59
|
+
return JSONResponse(
|
|
60
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
61
|
+
content=result.model_dump(mode="json"),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.post("/definitions/validate", response_model=ValidationResult)
|
|
66
|
+
async def validate_definition(body: JsonObject, state: State, caller: Caller) -> ValidationResult:
|
|
67
|
+
"""Always 200 with a ValidationResult, even when invalid (SPEC §6.1)."""
|
|
68
|
+
return await state.definitions.validate(dict(body))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.post("/definitions", status_code=status.HTTP_201_CREATED, response_model=DefinitionInfo)
|
|
72
|
+
async def create_definition(
|
|
73
|
+
body: FlowDefinition, state: State, caller: Caller
|
|
74
|
+
) -> DefinitionInfo | JSONResponse:
|
|
75
|
+
try:
|
|
76
|
+
return await state.definitions.create_draft(body, caller.sub)
|
|
77
|
+
except DefinitionInvalidError as exc:
|
|
78
|
+
return _validation_response(exc.result)
|
|
79
|
+
except DefinitionConflictError as exc:
|
|
80
|
+
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.put("/definitions/{name}", response_model=DefinitionInfo)
|
|
84
|
+
async def update_definition(
|
|
85
|
+
name: str, body: FlowDefinition, state: State, caller: Caller
|
|
86
|
+
) -> DefinitionInfo | JSONResponse:
|
|
87
|
+
try:
|
|
88
|
+
return await state.definitions.update_draft(name, body)
|
|
89
|
+
except DefinitionInvalidError as exc:
|
|
90
|
+
return _validation_response(exc.result)
|
|
91
|
+
except DefinitionNotFoundError:
|
|
92
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no definition {name!r}") from None
|
|
93
|
+
except DefinitionConflictError as exc:
|
|
94
|
+
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.post("/definitions/{name}/deploy", response_model=DeploymentInfo)
|
|
98
|
+
async def deploy_definition(
|
|
99
|
+
name: str,
|
|
100
|
+
state: State,
|
|
101
|
+
caller: Caller,
|
|
102
|
+
version: Annotated[int | None, Query(ge=1)] = None,
|
|
103
|
+
ephemeral: Annotated[bool, Query()] = False,
|
|
104
|
+
) -> DeploymentInfo | JSONResponse:
|
|
105
|
+
try:
|
|
106
|
+
return await state.definitions.deploy(name, version=version, ephemeral=ephemeral)
|
|
107
|
+
except DefinitionInvalidError as exc:
|
|
108
|
+
return _validation_response(exc.result)
|
|
109
|
+
except DefinitionNotFoundError as exc:
|
|
110
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) from None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@router.post("/definitions/{name}/undeploy", status_code=status.HTTP_204_NO_CONTENT)
|
|
114
|
+
async def undeploy_definition(name: str, state: State, caller: Caller) -> None:
|
|
115
|
+
try:
|
|
116
|
+
await state.definitions.undeploy(name)
|
|
117
|
+
except DefinitionNotFoundError:
|
|
118
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no definition {name!r}") from None
|
|
119
|
+
except DefinitionStateError as exc:
|
|
120
|
+
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.get("/definitions", response_model=list[DefinitionInfo])
|
|
124
|
+
async def list_definitions(
|
|
125
|
+
state: State,
|
|
126
|
+
caller: Caller,
|
|
127
|
+
status_filter: Annotated[
|
|
128
|
+
Literal["draft", "deployed", "undeployed"] | None, Query(alias="status")
|
|
129
|
+
] = None,
|
|
130
|
+
) -> list[DefinitionInfo]:
|
|
131
|
+
return await state.definitions.list(status_filter)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@router.get("/definitions/{name}", response_model=DefinitionInfo)
|
|
135
|
+
async def get_definition(
|
|
136
|
+
name: str,
|
|
137
|
+
state: State,
|
|
138
|
+
caller: Caller,
|
|
139
|
+
include: Annotated[Literal["definition"] | None, Query()] = None,
|
|
140
|
+
) -> DefinitionInfo:
|
|
141
|
+
try:
|
|
142
|
+
return await state.definitions.info(name, include_definition=include == "definition")
|
|
143
|
+
except DefinitionNotFoundError:
|
|
144
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no definition {name!r}") from None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@router.get("/definitions/{name}/export")
|
|
148
|
+
async def export_definition(
|
|
149
|
+
name: str,
|
|
150
|
+
state: State,
|
|
151
|
+
caller: Caller,
|
|
152
|
+
version: Annotated[int | None, Query(ge=1)] = None,
|
|
153
|
+
) -> JsonObject:
|
|
154
|
+
try:
|
|
155
|
+
defn = await state.definitions.export(name, version)
|
|
156
|
+
except DefinitionNotFoundError as exc:
|
|
157
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) from None
|
|
158
|
+
return defn.canonical_dict()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@router.delete("/definitions/{name}", status_code=status.HTTP_204_NO_CONTENT)
|
|
162
|
+
async def delete_definition(name: str, state: State, caller: Caller) -> None:
|
|
163
|
+
try:
|
|
164
|
+
await state.definitions.delete(name)
|
|
165
|
+
except DefinitionNotFoundError:
|
|
166
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no definition {name!r}") from None
|
|
167
|
+
except DefinitionStateError as exc:
|
|
168
|
+
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@router.post("/resources", status_code=status.HTTP_201_CREATED, response_model=Resource)
|
|
172
|
+
async def create_resource(body: Resource, state: State, caller: Caller) -> Resource:
|
|
173
|
+
try:
|
|
174
|
+
return await state.resources.create(body, caller.sub)
|
|
175
|
+
except ResourceConflictError as exc:
|
|
176
|
+
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from None
|
|
177
|
+
except ResourceValidationError as exc:
|
|
178
|
+
raise HTTPException(
|
|
179
|
+
status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.result.model_dump(mode="json")
|
|
180
|
+
) from None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@router.get("/resources", response_model=list[Resource])
|
|
184
|
+
async def list_resources(
|
|
185
|
+
state: State,
|
|
186
|
+
caller: Caller,
|
|
187
|
+
kind: Annotated[str | None, Query()] = None,
|
|
188
|
+
) -> list[Resource]:
|
|
189
|
+
return await state.resources.list(kind)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@router.get("/resources/{name}", response_model=Resource)
|
|
193
|
+
async def get_resource(name: str, state: State, caller: Caller) -> Resource:
|
|
194
|
+
try:
|
|
195
|
+
return await state.resources.get(name)
|
|
196
|
+
except ResourceNotFoundError:
|
|
197
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no resource {name!r}") from None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@router.put("/resources/{name}", response_model=Resource)
|
|
201
|
+
async def update_resource(name: str, body: Resource, state: State, caller: Caller) -> Resource:
|
|
202
|
+
try:
|
|
203
|
+
return await state.resources.update(name, body, caller.sub)
|
|
204
|
+
except ResourceNotFoundError:
|
|
205
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no resource {name!r}") from None
|
|
206
|
+
except ResourceValidationError as exc:
|
|
207
|
+
raise HTTPException(
|
|
208
|
+
status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.result.model_dump(mode="json")
|
|
209
|
+
) from None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@router.delete("/resources/{name}", status_code=status.HTTP_204_NO_CONTENT)
|
|
213
|
+
async def delete_resource(name: str, state: State, caller: Caller) -> None:
|
|
214
|
+
try:
|
|
215
|
+
await state.resources.delete(name)
|
|
216
|
+
except ResourceNotFoundError:
|
|
217
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no resource {name!r}") from None
|
|
218
|
+
except ResourceConflictError as exc:
|
|
219
|
+
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@health_router.get("/healthz")
|
|
223
|
+
async def healthz() -> dict[str, str]:
|
|
224
|
+
return {"status": "ok"}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@health_router.get("/readyz")
|
|
228
|
+
async def readyz() -> dict[str, str]:
|
|
229
|
+
return {"status": "ready"}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
__all__ = ["RuntimeState", "health_router", "router"]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Runtime app factory: API + dynamic /a2a and /mcp endpoint serving."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI
|
|
12
|
+
|
|
13
|
+
from agentplane_runtime.api import RuntimeState, health_router, router
|
|
14
|
+
from agentplane_runtime.auth import Authenticator
|
|
15
|
+
from agentplane_runtime.db import Database
|
|
16
|
+
from agentplane_runtime.definitions import DefinitionService
|
|
17
|
+
from agentplane_runtime.registration import RegistryRegistrar
|
|
18
|
+
from agentplane_runtime.resources import ResourceService
|
|
19
|
+
from agentplane_runtime.secrets import FernetSecretsProvider
|
|
20
|
+
from agentplane_runtime.serving import EndpointManager
|
|
21
|
+
from agentplane_runtime.settings import RUNTIME_VERSION, RuntimeSettings
|
|
22
|
+
from agentplane_runtime.tracing import setup_tracing
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_REAP_INTERVAL_S = 60.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_app(settings: RuntimeSettings | None = None) -> FastAPI:
|
|
30
|
+
cfg = settings or RuntimeSettings()
|
|
31
|
+
if not cfg.public_base_url:
|
|
32
|
+
raise RuntimeError("AGENTPLANE_RUNTIME_PUBLIC_BASE_URL is required")
|
|
33
|
+
if not cfg.secret_key:
|
|
34
|
+
raise RuntimeError("AGENTPLANE_RUNTIME_SECRET_KEY is required")
|
|
35
|
+
|
|
36
|
+
db = Database(cfg.db_url)
|
|
37
|
+
secrets = FernetSecretsProvider(db, cfg.secret_key)
|
|
38
|
+
resources = ResourceService(db, secrets)
|
|
39
|
+
endpoints = EndpointManager(resources, cfg)
|
|
40
|
+
registrar = RegistryRegistrar(cfg)
|
|
41
|
+
definitions = DefinitionService(db, resources, endpoints, registrar)
|
|
42
|
+
|
|
43
|
+
@asynccontextmanager
|
|
44
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
45
|
+
await db.create_all()
|
|
46
|
+
try:
|
|
47
|
+
await definitions.restore_deployed_endpoints()
|
|
48
|
+
except Exception:
|
|
49
|
+
logger.exception("failed to restore deployed endpoints")
|
|
50
|
+
|
|
51
|
+
async def reaper() -> None:
|
|
52
|
+
while True:
|
|
53
|
+
await asyncio.sleep(_REAP_INTERVAL_S)
|
|
54
|
+
await endpoints.reap_expired()
|
|
55
|
+
|
|
56
|
+
reap_task = asyncio.create_task(reaper(), name="ephemeral-reaper")
|
|
57
|
+
try:
|
|
58
|
+
yield
|
|
59
|
+
finally:
|
|
60
|
+
reap_task.cancel()
|
|
61
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
62
|
+
await reap_task
|
|
63
|
+
await registrar.shutdown()
|
|
64
|
+
await endpoints.stop_all()
|
|
65
|
+
await db.dispose()
|
|
66
|
+
|
|
67
|
+
app = FastAPI(title="agentplane-runtime", version=RUNTIME_VERSION, lifespan=lifespan)
|
|
68
|
+
app.state.runtime = RuntimeState(definitions=definitions, resources=resources)
|
|
69
|
+
app.state.authenticator = Authenticator(cfg)
|
|
70
|
+
app.state.endpoints = endpoints
|
|
71
|
+
app.include_router(router)
|
|
72
|
+
app.include_router(health_router)
|
|
73
|
+
app.mount("/a2a", endpoints.a2a)
|
|
74
|
+
app.mount("/mcp", endpoints.mcp)
|
|
75
|
+
setup_tracing(app, service_name="agentplane-runtime")
|
|
76
|
+
return app
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main() -> None: # pragma: no cover - process entrypoint
|
|
80
|
+
import uvicorn # noqa: PLC0415 - only needed for the entrypoint
|
|
81
|
+
|
|
82
|
+
uvicorn.run(create_app(), host="0.0.0.0", port=8000)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = ["create_app", "main"]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Optional OIDC auth for the runtime API (SPEC §7.1), same model as the registry.
|
|
2
|
+
|
|
3
|
+
Deliberately a sibling implementation — services never import each other
|
|
4
|
+
(architecture invariant 3), and core stays free of HTTP/JWT dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import jwt
|
|
14
|
+
from fastapi import HTTPException, Request, status
|
|
15
|
+
from jwt.types import Options
|
|
16
|
+
|
|
17
|
+
from agentplane_runtime.settings import RuntimeSettings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Principal:
|
|
22
|
+
sub: str
|
|
23
|
+
roles: frozenset[str] = field(default_factory=frozenset)
|
|
24
|
+
is_admin: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ANONYMOUS = Principal(sub="anonymous", roles=frozenset(), is_admin=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _claim_path(claims: dict[str, object], path: str) -> object:
|
|
31
|
+
value: object = claims
|
|
32
|
+
for part in path.split("."):
|
|
33
|
+
if not isinstance(value, dict):
|
|
34
|
+
return None
|
|
35
|
+
value = value.get(part)
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OidcValidator:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
issuer: str,
|
|
43
|
+
audience: str,
|
|
44
|
+
roles_claim: str,
|
|
45
|
+
admin_role: str,
|
|
46
|
+
*,
|
|
47
|
+
jwks_ttl_s: float = 300.0,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._issuer = issuer.rstrip("/")
|
|
50
|
+
self._audience = audience
|
|
51
|
+
self._roles_claim = roles_claim
|
|
52
|
+
self._admin_role = admin_role
|
|
53
|
+
self._jwks_ttl_s = jwks_ttl_s
|
|
54
|
+
self._jwks: dict[str, jwt.PyJWK] = {}
|
|
55
|
+
self._jwks_fetched_at = 0.0
|
|
56
|
+
|
|
57
|
+
async def _refresh_jwks(self) -> None:
|
|
58
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
59
|
+
discovery = await client.get(f"{self._issuer}/.well-known/openid-configuration")
|
|
60
|
+
discovery.raise_for_status()
|
|
61
|
+
jwks_uri = discovery.json().get("jwks_uri")
|
|
62
|
+
if not isinstance(jwks_uri, str):
|
|
63
|
+
raise RuntimeError("issuer discovery returned no jwks_uri")
|
|
64
|
+
jwks_response = await client.get(jwks_uri)
|
|
65
|
+
jwks_response.raise_for_status()
|
|
66
|
+
keys = jwt.PyJWKSet.from_dict(jwks_response.json()).keys
|
|
67
|
+
self._jwks = {key.key_id: key for key in keys if key.key_id}
|
|
68
|
+
self._jwks_fetched_at = time.monotonic()
|
|
69
|
+
|
|
70
|
+
async def _key_for(self, kid: str) -> jwt.PyJWK:
|
|
71
|
+
stale = time.monotonic() - self._jwks_fetched_at > self._jwks_ttl_s
|
|
72
|
+
if kid not in self._jwks or stale:
|
|
73
|
+
await self._refresh_jwks()
|
|
74
|
+
key = self._jwks.get(kid)
|
|
75
|
+
if key is None:
|
|
76
|
+
raise jwt.InvalidTokenError(f"unknown key id {kid!r}")
|
|
77
|
+
return key
|
|
78
|
+
|
|
79
|
+
async def validate(self, token: str) -> Principal:
|
|
80
|
+
header = jwt.get_unverified_header(token)
|
|
81
|
+
kid = header.get("kid")
|
|
82
|
+
if not isinstance(kid, str):
|
|
83
|
+
raise jwt.InvalidTokenError("token has no kid header")
|
|
84
|
+
key = await self._key_for(kid)
|
|
85
|
+
options: Options = {"verify_aud": bool(self._audience)}
|
|
86
|
+
claims = jwt.decode(
|
|
87
|
+
token,
|
|
88
|
+
key=key.key,
|
|
89
|
+
algorithms=["RS256", "ES256"],
|
|
90
|
+
audience=self._audience or None,
|
|
91
|
+
issuer=self._issuer,
|
|
92
|
+
options=options,
|
|
93
|
+
)
|
|
94
|
+
roles_value = _claim_path(claims, self._roles_claim)
|
|
95
|
+
roles = (
|
|
96
|
+
frozenset(str(role) for role in roles_value)
|
|
97
|
+
if isinstance(roles_value, list)
|
|
98
|
+
else frozenset()
|
|
99
|
+
)
|
|
100
|
+
sub = claims.get("sub")
|
|
101
|
+
if not isinstance(sub, str) or not sub:
|
|
102
|
+
raise jwt.InvalidTokenError("token has no sub")
|
|
103
|
+
return Principal(sub=sub, roles=roles, is_admin=self._admin_role in roles)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Authenticator:
|
|
107
|
+
def __init__(self, settings: RuntimeSettings) -> None:
|
|
108
|
+
self.mode = settings.auth_mode
|
|
109
|
+
self._validator: OidcValidator | None = None
|
|
110
|
+
if self.mode == "oidc":
|
|
111
|
+
if not settings.oidc_issuer:
|
|
112
|
+
raise RuntimeError("AUTH_MODE=oidc requires AGENTPLANE_RUNTIME_OIDC_ISSUER")
|
|
113
|
+
self._validator = OidcValidator(
|
|
114
|
+
settings.oidc_issuer,
|
|
115
|
+
settings.oidc_audience,
|
|
116
|
+
settings.roles_claim,
|
|
117
|
+
settings.admin_role,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def authenticate(self, request: Request) -> Principal:
|
|
121
|
+
if self.mode == "none" or self._validator is None:
|
|
122
|
+
return ANONYMOUS
|
|
123
|
+
header = request.headers.get("Authorization", "")
|
|
124
|
+
scheme, _, token = header.partition(" ")
|
|
125
|
+
if scheme.lower() != "bearer" or not token:
|
|
126
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="missing bearer token")
|
|
127
|
+
try:
|
|
128
|
+
return await self._validator.validate(token)
|
|
129
|
+
except jwt.InvalidTokenError as exc:
|
|
130
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
|
131
|
+
except httpx.HTTPError as exc:
|
|
132
|
+
raise HTTPException(
|
|
133
|
+
status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"issuer unreachable: {exc}"
|
|
134
|
+
) from exc
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
__all__ = ["ANONYMOUS", "Authenticator", "OidcValidator", "Principal"]
|