darwin-agentic-cloud 0.1.0__py3-none-any.whl
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.
- darwin/__init__.py +10 -0
- darwin/agenticcloud/__init__.py +7 -0
- darwin/agenticcloud/attestation.py +77 -0
- darwin/agenticcloud/cli.py +502 -0
- darwin/agenticcloud/cost.py +76 -0
- darwin/agenticcloud/hashing.py +32 -0
- darwin/agenticcloud/mcp_server.py +303 -0
- darwin/agenticcloud/runtime.py +109 -0
- darwin/agenticcloud/sandbox.py +235 -0
- darwin/agenticcloud/server.py +230 -0
- darwin/agenticcloud/signing.py +100 -0
- darwin/agenticcloud/storage.py +230 -0
- darwin/agenticcloud/templates/docs.html +528 -0
- darwin/agenticcloud/types.py +57 -0
- darwin_agentic_cloud-0.1.0.dist-info/METADATA +444 -0
- darwin_agentic_cloud-0.1.0.dist-info/RECORD +19 -0
- darwin_agentic_cloud-0.1.0.dist-info/WHEEL +4 -0
- darwin_agentic_cloud-0.1.0.dist-info/entry_points.txt +2 -0
- darwin_agentic_cloud-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""DAC HTTP server (FastAPI).
|
|
2
|
+
|
|
3
|
+
Exposes the runtime over HTTP so any client — curl, a remote agent,
|
|
4
|
+
another service — can request signed execution and query history.
|
|
5
|
+
|
|
6
|
+
Endpoints:
|
|
7
|
+
GET /healthz — liveness
|
|
8
|
+
GET /v0/identity — server's public key + substrate id
|
|
9
|
+
POST /v0/run — execute workload, return signed attestation
|
|
10
|
+
POST /v0/attestations/verify — verify a signed attestation
|
|
11
|
+
GET /v0/attestations — list recent attestations
|
|
12
|
+
GET /v0/attestations/{id} — fetch a specific attestation
|
|
13
|
+
GET /v0/attestations/stats — aggregate stats
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from fastapi import FastAPI, HTTPException, Query
|
|
22
|
+
from pydantic import BaseModel, Field
|
|
23
|
+
|
|
24
|
+
from darwin import agenticcloud as dac
|
|
25
|
+
from darwin.agenticcloud.attestation import verify_attestation
|
|
26
|
+
from darwin.agenticcloud.runtime import Runtime
|
|
27
|
+
from darwin.agenticcloud.sandbox import SUBSTRATE_ID
|
|
28
|
+
from darwin.agenticcloud.signing import Signer
|
|
29
|
+
from darwin.agenticcloud.storage import AttestationStore
|
|
30
|
+
from darwin.agenticcloud.types import WorkloadSpec
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# -------------------------------------------------------------------
|
|
34
|
+
# Schemas
|
|
35
|
+
# -------------------------------------------------------------------
|
|
36
|
+
class RunRequest(BaseModel):
|
|
37
|
+
code: str = Field(..., description="Source code to execute.")
|
|
38
|
+
language: str = Field("python", description="Language: 'python' or 'node'.")
|
|
39
|
+
inputs: dict = Field(default_factory=dict, description="Optional inputs.")
|
|
40
|
+
cost_cap_usd: float = Field(0.01, ge=0, description="Cost ceiling in USD.")
|
|
41
|
+
timeout_sec: int = Field(30, ge=1, le=600, description="Timeout in seconds.")
|
|
42
|
+
memory_mb: int = Field(512, ge=64, le=8192, description="Memory limit in MB.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RunResponse(BaseModel):
|
|
46
|
+
attestation: dict
|
|
47
|
+
signature_b64: str
|
|
48
|
+
public_key_b64: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class VerifyRequest(BaseModel):
|
|
52
|
+
attestation: dict
|
|
53
|
+
signature_b64: str
|
|
54
|
+
public_key_b64: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class VerifyResponse(BaseModel):
|
|
58
|
+
verified: bool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class IdentityResponse(BaseModel):
|
|
62
|
+
key_id: str
|
|
63
|
+
public_key_b64: str
|
|
64
|
+
substrate_id: str
|
|
65
|
+
schema: str
|
|
66
|
+
version: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AttestationSummary(BaseModel):
|
|
70
|
+
attestation_id: str
|
|
71
|
+
workload_id: str
|
|
72
|
+
signer_key_id: str
|
|
73
|
+
substrate_id: str
|
|
74
|
+
status: str
|
|
75
|
+
issued_at: float
|
|
76
|
+
cost_usd: float
|
|
77
|
+
wall_time_sec: float
|
|
78
|
+
schema_version: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AttestationListResponse(BaseModel):
|
|
82
|
+
attestations: list[AttestationSummary]
|
|
83
|
+
count: int
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AttestationStatsResponse(BaseModel):
|
|
87
|
+
total_count: int
|
|
88
|
+
total_cost_usd: float
|
|
89
|
+
by_status: dict[str, int]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# -------------------------------------------------------------------
|
|
93
|
+
# App factory
|
|
94
|
+
# -------------------------------------------------------------------
|
|
95
|
+
def create_app(runtime: Runtime | None = None) -> FastAPI:
|
|
96
|
+
"""Build a FastAPI app with the given runtime (or a default one)."""
|
|
97
|
+
rt = runtime or Runtime()
|
|
98
|
+
store: AttestationStore = rt.store
|
|
99
|
+
|
|
100
|
+
app = FastAPI(
|
|
101
|
+
title="Darwin Agentic Cloud",
|
|
102
|
+
description="Verifiable compute for AI agents with cryptographically signed attestations.",
|
|
103
|
+
version=dac.__version__,
|
|
104
|
+
docs_url="/docs/swagger",
|
|
105
|
+
redoc_url="/docs/redoc",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@app.get("/healthz", tags=["health"])
|
|
109
|
+
async def healthz() -> dict[str, str]:
|
|
110
|
+
return {"status": "ok"}
|
|
111
|
+
|
|
112
|
+
@app.get("/v0/identity", response_model=IdentityResponse, tags=["identity"])
|
|
113
|
+
async def identity() -> IdentityResponse:
|
|
114
|
+
signer: Signer = rt.signer
|
|
115
|
+
return IdentityResponse(
|
|
116
|
+
key_id=signer.key_id(),
|
|
117
|
+
public_key_b64=signer.public_key_b64(),
|
|
118
|
+
substrate_id=SUBSTRATE_ID,
|
|
119
|
+
schema=dac.ATTESTATION_SCHEMA,
|
|
120
|
+
version=dac.__version__,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@app.post("/v0/run", response_model=RunResponse, tags=["run"])
|
|
124
|
+
async def run(req: RunRequest) -> RunResponse:
|
|
125
|
+
spec = WorkloadSpec(
|
|
126
|
+
code=req.code,
|
|
127
|
+
language=req.language,
|
|
128
|
+
inputs=req.inputs,
|
|
129
|
+
cost_cap_usd=req.cost_cap_usd,
|
|
130
|
+
timeout_sec=req.timeout_sec,
|
|
131
|
+
memory_mb=req.memory_mb,
|
|
132
|
+
)
|
|
133
|
+
signed = await asyncio.to_thread(rt.run, spec)
|
|
134
|
+
return RunResponse(
|
|
135
|
+
attestation=signed.attestation,
|
|
136
|
+
signature_b64=signed.signature_b64,
|
|
137
|
+
public_key_b64=signed.public_key_b64,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
@app.post("/v0/attestations/verify", response_model=VerifyResponse, tags=["attestations"])
|
|
141
|
+
async def verify(req: VerifyRequest) -> VerifyResponse:
|
|
142
|
+
try:
|
|
143
|
+
payload: dict[str, Any] = {
|
|
144
|
+
"attestation": req.attestation,
|
|
145
|
+
"signature_b64": req.signature_b64,
|
|
146
|
+
"public_key_b64": req.public_key_b64,
|
|
147
|
+
}
|
|
148
|
+
return VerifyResponse(verified=verify_attestation(payload))
|
|
149
|
+
except Exception as e:
|
|
150
|
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
151
|
+
|
|
152
|
+
@app.get(
|
|
153
|
+
"/v0/attestations/stats",
|
|
154
|
+
response_model=AttestationStatsResponse,
|
|
155
|
+
tags=["attestations"],
|
|
156
|
+
)
|
|
157
|
+
async def stats() -> AttestationStatsResponse:
|
|
158
|
+
total = store.count()
|
|
159
|
+
total_cost = store.total_cost_usd()
|
|
160
|
+
by_status = {
|
|
161
|
+
"ok": len(store.list_by_status("ok", limit=10**9)),
|
|
162
|
+
"error": len(store.list_by_status("error", limit=10**9)),
|
|
163
|
+
"timeout": len(store.list_by_status("timeout", limit=10**9)),
|
|
164
|
+
"oom": len(store.list_by_status("oom", limit=10**9)),
|
|
165
|
+
"cost_exceeded": len(store.list_by_status("cost_exceeded", limit=10**9)),
|
|
166
|
+
}
|
|
167
|
+
return AttestationStatsResponse(
|
|
168
|
+
total_count=total,
|
|
169
|
+
total_cost_usd=total_cost,
|
|
170
|
+
by_status=by_status,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
@app.get(
|
|
174
|
+
"/v0/attestations",
|
|
175
|
+
response_model=AttestationListResponse,
|
|
176
|
+
tags=["attestations"],
|
|
177
|
+
)
|
|
178
|
+
async def list_attestations(
|
|
179
|
+
limit: int = Query(20, ge=1, le=1000),
|
|
180
|
+
status: str | None = Query(None),
|
|
181
|
+
) -> AttestationListResponse:
|
|
182
|
+
rows = (
|
|
183
|
+
store.list_by_status(status, limit=limit) if status else store.list_recent(limit=limit)
|
|
184
|
+
)
|
|
185
|
+
return AttestationListResponse(
|
|
186
|
+
attestations=[
|
|
187
|
+
AttestationSummary(
|
|
188
|
+
attestation_id=r.attestation_id,
|
|
189
|
+
workload_id=r.workload_id,
|
|
190
|
+
signer_key_id=r.signer_key_id,
|
|
191
|
+
substrate_id=r.substrate_id,
|
|
192
|
+
status=r.status,
|
|
193
|
+
issued_at=r.issued_at,
|
|
194
|
+
cost_usd=r.cost_usd,
|
|
195
|
+
wall_time_sec=r.wall_time_sec,
|
|
196
|
+
schema_version=r.schema_version,
|
|
197
|
+
)
|
|
198
|
+
for r in rows
|
|
199
|
+
],
|
|
200
|
+
count=len(rows),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@app.get("/v0/attestations/{attestation_id}", tags=["attestations"])
|
|
204
|
+
async def get_attestation(attestation_id: str) -> dict:
|
|
205
|
+
fetched = store.get(attestation_id)
|
|
206
|
+
if fetched is None:
|
|
207
|
+
raise HTTPException(status_code=404, detail="Attestation not found")
|
|
208
|
+
return fetched.signed_attestation
|
|
209
|
+
|
|
210
|
+
# Custom branded docs page (Material 3, dark, brand colors)
|
|
211
|
+
from pathlib import Path as _Path
|
|
212
|
+
|
|
213
|
+
from fastapi.responses import HTMLResponse
|
|
214
|
+
|
|
215
|
+
_TEMPLATE_PATH = _Path(__file__).parent / "templates" / "docs.html"
|
|
216
|
+
|
|
217
|
+
@app.get("/docs", response_class=HTMLResponse, include_in_schema=False)
|
|
218
|
+
async def custom_docs() -> HTMLResponse:
|
|
219
|
+
"""Serve the branded Darwin docs page."""
|
|
220
|
+
return HTMLResponse(_TEMPLATE_PATH.read_text(encoding="utf-8"))
|
|
221
|
+
|
|
222
|
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|
223
|
+
async def root() -> HTMLResponse:
|
|
224
|
+
"""Root: redirect to the docs page."""
|
|
225
|
+
return HTMLResponse(_TEMPLATE_PATH.read_text(encoding="utf-8"))
|
|
226
|
+
|
|
227
|
+
return app
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
app = create_app()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Ed25519 signing for DAC attestations.
|
|
2
|
+
|
|
3
|
+
Stores a local keypair at ~/.darwin/agenticcloud/keys/signing.pem. In production this
|
|
4
|
+
gets swapped for Sigstore + OIDC, but the Signer interface stays the
|
|
5
|
+
same so callers don't change.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from cryptography.hazmat.primitives import serialization
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
15
|
+
Ed25519PrivateKey,
|
|
16
|
+
Ed25519PublicKey,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from darwin.agenticcloud.hashing import sha256_hex
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _default_key_dir() -> Path:
|
|
23
|
+
"""Default keys directory, honoring DARWIN_STATE_DIR env var."""
|
|
24
|
+
import os
|
|
25
|
+
|
|
26
|
+
state_dir = os.environ.get("DARWIN_STATE_DIR")
|
|
27
|
+
if state_dir:
|
|
28
|
+
return Path(state_dir) / "keys"
|
|
29
|
+
return Path.home() / ".darwin" / "agenticcloud" / "keys"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
DEFAULT_KEY_DIR = _default_key_dir()
|
|
33
|
+
DEFAULT_KEY_PATH = DEFAULT_KEY_DIR / "signing.pem"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Signer:
|
|
37
|
+
"""Local Ed25519 signer."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, key_path: Path | None = None) -> None:
|
|
40
|
+
self.key_path = key_path or DEFAULT_KEY_PATH
|
|
41
|
+
self._private_key = self._load_or_create()
|
|
42
|
+
self._public_key = self._private_key.public_key()
|
|
43
|
+
|
|
44
|
+
def _load_or_create(self) -> Ed25519PrivateKey:
|
|
45
|
+
if self.key_path.exists():
|
|
46
|
+
return self._load()
|
|
47
|
+
return self._create()
|
|
48
|
+
|
|
49
|
+
def _load(self) -> Ed25519PrivateKey:
|
|
50
|
+
with self.key_path.open("rb") as f:
|
|
51
|
+
key = serialization.load_pem_private_key(f.read(), password=None)
|
|
52
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
53
|
+
raise TypeError(f"Expected Ed25519 key at {self.key_path}, got {type(key).__name__}")
|
|
54
|
+
return key
|
|
55
|
+
|
|
56
|
+
def _create(self) -> Ed25519PrivateKey:
|
|
57
|
+
self.key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
key = Ed25519PrivateKey.generate()
|
|
59
|
+
pem = key.private_bytes(
|
|
60
|
+
encoding=serialization.Encoding.PEM,
|
|
61
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
62
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
63
|
+
)
|
|
64
|
+
self.key_path.write_bytes(pem)
|
|
65
|
+
self.key_path.chmod(0o600)
|
|
66
|
+
return key
|
|
67
|
+
|
|
68
|
+
def _public_key_raw(self) -> bytes:
|
|
69
|
+
return self._public_key.public_bytes(
|
|
70
|
+
encoding=serialization.Encoding.Raw,
|
|
71
|
+
format=serialization.PublicFormat.Raw,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def public_key_b64(self) -> str:
|
|
75
|
+
"""Base64-encoded raw Ed25519 public key (32 bytes -> 44 chars)."""
|
|
76
|
+
return base64.b64encode(self._public_key_raw()).decode()
|
|
77
|
+
|
|
78
|
+
def key_id(self) -> str:
|
|
79
|
+
"""Stable identifier for this signing key.
|
|
80
|
+
|
|
81
|
+
Format: dac-local-<first 16 hex chars of sha256(public_key)>
|
|
82
|
+
"""
|
|
83
|
+
return "dac-local-" + sha256_hex(self._public_key_raw())[:16]
|
|
84
|
+
|
|
85
|
+
def sign(self, data: bytes) -> str:
|
|
86
|
+
"""Sign data, return base64-encoded signature."""
|
|
87
|
+
sig = self._private_key.sign(data)
|
|
88
|
+
return base64.b64encode(sig).decode()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def verify_signature(data: bytes, signature_b64: str, public_key_b64: str) -> bool:
|
|
92
|
+
"""Verify a base64-encoded signature against base64 public key."""
|
|
93
|
+
try:
|
|
94
|
+
sig = base64.b64decode(signature_b64)
|
|
95
|
+
pub_raw = base64.b64decode(public_key_b64)
|
|
96
|
+
pub = Ed25519PublicKey.from_public_bytes(pub_raw)
|
|
97
|
+
pub.verify(sig, data)
|
|
98
|
+
except Exception:
|
|
99
|
+
return False
|
|
100
|
+
return True
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Persistent storage of attestations.
|
|
2
|
+
|
|
3
|
+
Every signed attestation is written to a local SQLite database so it
|
|
4
|
+
can be queried and audited later. This is what turns DAC from a
|
|
5
|
+
stateless RPC into an evidentiary system of record.
|
|
6
|
+
|
|
7
|
+
Schema is intentionally minimal for v0:
|
|
8
|
+
- attestations: the full signed payload, plus indexed columns we'd
|
|
9
|
+
reasonably query by (issued_at, signer_key_id, status, workload_id,
|
|
10
|
+
substrate_id, attestation_id).
|
|
11
|
+
|
|
12
|
+
The 'signed_attestation_json' column holds the entire SignedAttestation
|
|
13
|
+
as JSON so verification is independent of schema changes — you can
|
|
14
|
+
always re-verify a stored attestation by deserializing that column.
|
|
15
|
+
|
|
16
|
+
In v0.2 we'll add a SQLAlchemy-backed Postgres implementation behind
|
|
17
|
+
the same AttestationStore interface for multi-tenant deployments.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import sqlite3
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
from contextlib import contextmanager
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from darwin.agenticcloud.types import SignedAttestation
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _default_db_path() -> Path:
|
|
33
|
+
"""Return the default attestation DB path, honoring DARWIN_STATE_DIR."""
|
|
34
|
+
import os
|
|
35
|
+
|
|
36
|
+
state_dir = os.environ.get("DARWIN_STATE_DIR")
|
|
37
|
+
if state_dir:
|
|
38
|
+
return Path(state_dir) / "attestations.db"
|
|
39
|
+
return Path.home() / ".darwin" / "agenticcloud" / "attestations.db"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
DEFAULT_DB_PATH = _default_db_path()
|
|
43
|
+
|
|
44
|
+
SCHEMA = """
|
|
45
|
+
CREATE TABLE IF NOT EXISTS attestations (
|
|
46
|
+
attestation_id TEXT PRIMARY KEY,
|
|
47
|
+
workload_id TEXT NOT NULL,
|
|
48
|
+
signer_key_id TEXT NOT NULL,
|
|
49
|
+
substrate_id TEXT NOT NULL,
|
|
50
|
+
status TEXT NOT NULL,
|
|
51
|
+
issued_at REAL NOT NULL,
|
|
52
|
+
cost_usd REAL NOT NULL,
|
|
53
|
+
wall_time_sec REAL NOT NULL,
|
|
54
|
+
schema_version TEXT NOT NULL,
|
|
55
|
+
signed_attestation_json TEXT NOT NULL
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_attestations_issued_at
|
|
59
|
+
ON attestations(issued_at DESC);
|
|
60
|
+
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_attestations_workload_id
|
|
62
|
+
ON attestations(workload_id);
|
|
63
|
+
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_attestations_signer_key_id
|
|
65
|
+
ON attestations(signer_key_id);
|
|
66
|
+
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_attestations_status
|
|
68
|
+
ON attestations(status);
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class StoredAttestation:
|
|
74
|
+
"""A row in the attestations table, returned by queries."""
|
|
75
|
+
|
|
76
|
+
attestation_id: str
|
|
77
|
+
workload_id: str
|
|
78
|
+
signer_key_id: str
|
|
79
|
+
substrate_id: str
|
|
80
|
+
status: str
|
|
81
|
+
issued_at: float
|
|
82
|
+
cost_usd: float
|
|
83
|
+
wall_time_sec: float
|
|
84
|
+
schema_version: str
|
|
85
|
+
signed_attestation: dict # the full SignedAttestation re-hydrated
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AttestationStore:
|
|
89
|
+
"""SQLite-backed attestation store.
|
|
90
|
+
|
|
91
|
+
Thread-safe at the connection level via SQLite's serialized access.
|
|
92
|
+
For higher concurrency in v0.2 we move to a connection pool or
|
|
93
|
+
swap the backend to Postgres.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, db_path: Path | None = None) -> None:
|
|
97
|
+
self.db_path = db_path or DEFAULT_DB_PATH
|
|
98
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
self._init_schema()
|
|
100
|
+
|
|
101
|
+
def _init_schema(self) -> None:
|
|
102
|
+
with self._connect() as conn:
|
|
103
|
+
conn.executescript(SCHEMA)
|
|
104
|
+
|
|
105
|
+
@contextmanager
|
|
106
|
+
def _connect(self) -> Iterator[sqlite3.Connection]:
|
|
107
|
+
conn = sqlite3.connect(self.db_path, isolation_level=None)
|
|
108
|
+
conn.row_factory = sqlite3.Row
|
|
109
|
+
try:
|
|
110
|
+
yield conn
|
|
111
|
+
finally:
|
|
112
|
+
conn.close()
|
|
113
|
+
|
|
114
|
+
# -------------------------------------------------------------
|
|
115
|
+
# Writes
|
|
116
|
+
# -------------------------------------------------------------
|
|
117
|
+
def save(self, signed: SignedAttestation) -> None:
|
|
118
|
+
"""Persist a signed attestation. Idempotent on attestation_id."""
|
|
119
|
+
a = signed.attestation
|
|
120
|
+
er = a["execution_result"]
|
|
121
|
+
signed_json = json.dumps(
|
|
122
|
+
{
|
|
123
|
+
"attestation": signed.attestation,
|
|
124
|
+
"signature_b64": signed.signature_b64,
|
|
125
|
+
"public_key_b64": signed.public_key_b64,
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
with self._connect() as conn:
|
|
129
|
+
conn.execute(
|
|
130
|
+
"""
|
|
131
|
+
INSERT OR REPLACE INTO attestations (
|
|
132
|
+
attestation_id, workload_id, signer_key_id, substrate_id,
|
|
133
|
+
status, issued_at, cost_usd, wall_time_sec,
|
|
134
|
+
schema_version, signed_attestation_json
|
|
135
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
136
|
+
""",
|
|
137
|
+
(
|
|
138
|
+
a["attestation_id"],
|
|
139
|
+
er["workload_id"],
|
|
140
|
+
a["signer_key_id"],
|
|
141
|
+
er["substrate_id"],
|
|
142
|
+
er["status"],
|
|
143
|
+
a["issued_at"],
|
|
144
|
+
er["cost_usd"],
|
|
145
|
+
er["wall_time_sec"],
|
|
146
|
+
a["schema"],
|
|
147
|
+
signed_json,
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# -------------------------------------------------------------
|
|
152
|
+
# Reads
|
|
153
|
+
# -------------------------------------------------------------
|
|
154
|
+
def get(self, attestation_id: str) -> StoredAttestation | None:
|
|
155
|
+
"""Fetch a single attestation by ID. Returns None if not found."""
|
|
156
|
+
with self._connect() as conn:
|
|
157
|
+
row = conn.execute(
|
|
158
|
+
"SELECT * FROM attestations WHERE attestation_id = ?",
|
|
159
|
+
(attestation_id,),
|
|
160
|
+
).fetchone()
|
|
161
|
+
return _row_to_stored(row) if row else None
|
|
162
|
+
|
|
163
|
+
def list_recent(self, limit: int = 50) -> list[StoredAttestation]:
|
|
164
|
+
"""Return the most recent attestations, newest first."""
|
|
165
|
+
with self._connect() as conn:
|
|
166
|
+
rows = conn.execute(
|
|
167
|
+
"SELECT * FROM attestations ORDER BY issued_at DESC LIMIT ?",
|
|
168
|
+
(limit,),
|
|
169
|
+
).fetchall()
|
|
170
|
+
return [_row_to_stored(r) for r in rows]
|
|
171
|
+
|
|
172
|
+
def list_by_status(self, status: str, limit: int = 50) -> list[StoredAttestation]:
|
|
173
|
+
"""Return attestations with a given status (e.g. 'cost_exceeded')."""
|
|
174
|
+
with self._connect() as conn:
|
|
175
|
+
rows = conn.execute(
|
|
176
|
+
"""
|
|
177
|
+
SELECT * FROM attestations
|
|
178
|
+
WHERE status = ?
|
|
179
|
+
ORDER BY issued_at DESC
|
|
180
|
+
LIMIT ?
|
|
181
|
+
""",
|
|
182
|
+
(status, limit),
|
|
183
|
+
).fetchall()
|
|
184
|
+
return [_row_to_stored(r) for r in rows]
|
|
185
|
+
|
|
186
|
+
def list_by_signer(self, signer_key_id: str, limit: int = 50) -> list[StoredAttestation]:
|
|
187
|
+
"""Return attestations issued by a given signer."""
|
|
188
|
+
with self._connect() as conn:
|
|
189
|
+
rows = conn.execute(
|
|
190
|
+
"""
|
|
191
|
+
SELECT * FROM attestations
|
|
192
|
+
WHERE signer_key_id = ?
|
|
193
|
+
ORDER BY issued_at DESC
|
|
194
|
+
LIMIT ?
|
|
195
|
+
""",
|
|
196
|
+
(signer_key_id, limit),
|
|
197
|
+
).fetchall()
|
|
198
|
+
return [_row_to_stored(r) for r in rows]
|
|
199
|
+
|
|
200
|
+
def count(self) -> int:
|
|
201
|
+
"""Total number of stored attestations."""
|
|
202
|
+
with self._connect() as conn:
|
|
203
|
+
return conn.execute("SELECT COUNT(*) FROM attestations").fetchone()[0]
|
|
204
|
+
|
|
205
|
+
def total_cost_usd(self, signer_key_id: str | None = None) -> float:
|
|
206
|
+
"""Sum of cost_usd across all (or one signer's) attestations."""
|
|
207
|
+
with self._connect() as conn:
|
|
208
|
+
if signer_key_id is None:
|
|
209
|
+
row = conn.execute("SELECT COALESCE(SUM(cost_usd), 0) FROM attestations").fetchone()
|
|
210
|
+
else:
|
|
211
|
+
row = conn.execute(
|
|
212
|
+
"SELECT COALESCE(SUM(cost_usd), 0) FROM attestations WHERE signer_key_id = ?",
|
|
213
|
+
(signer_key_id,),
|
|
214
|
+
).fetchone()
|
|
215
|
+
return float(row[0])
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _row_to_stored(row: sqlite3.Row) -> StoredAttestation:
|
|
219
|
+
return StoredAttestation(
|
|
220
|
+
attestation_id=row["attestation_id"],
|
|
221
|
+
workload_id=row["workload_id"],
|
|
222
|
+
signer_key_id=row["signer_key_id"],
|
|
223
|
+
substrate_id=row["substrate_id"],
|
|
224
|
+
status=row["status"],
|
|
225
|
+
issued_at=row["issued_at"],
|
|
226
|
+
cost_usd=row["cost_usd"],
|
|
227
|
+
wall_time_sec=row["wall_time_sec"],
|
|
228
|
+
schema_version=row["schema_version"],
|
|
229
|
+
signed_attestation=json.loads(row["signed_attestation_json"]),
|
|
230
|
+
)
|