Skip to content

Commit ac89757

Browse files
author
bussyjd
committed
fix: restore /skill.md publishing dropped during cherry-pick
The cherry-pick of PR #265 dropped _build_skill_md() and _publish_skill_md() from monetize.py, along with their 3 call sites in cmd_process. This meant /skill.md would never be created or updated on a fresh cluster. Restores: - _build_skill_md(): generates service catalog markdown from Ready offers - _publish_skill_md(): creates/updates ConfigMap + Deployment + Service + HTTPRoute for the /skill.md endpoint - 3 call sites in cmd_process: 1. Empty skill.md when no offers exist 2. Full skill.md when all offers are Ready 3. Regenerate after reconciliation loop
1 parent d81316f commit ac89757

1 file changed

Lines changed: 255 additions & 0 deletions

File tree

internal/embed/skills/sell/scripts/monetize.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,245 @@ def _apply_resource(collection_path, name, resource, token, ssl_ctx):
13291329
raise RuntimeError(f"K8s API error {e.code} for {name}") from e
13301330

13311331

1332+
def _build_skill_md(items, base_url):
1333+
"""Build /skill.md content from all Ready ServiceOffer items."""
1334+
ready = []
1335+
for item in items:
1336+
conditions = item.get("status", {}).get("conditions", [])
1337+
if is_condition_true(conditions, "Ready"):
1338+
ready.append(item)
1339+
1340+
agent_name = "Obol Stack"
1341+
if ready:
1342+
reg = ready[0].get("spec", {}).get("registration", {})
1343+
if reg.get("name"):
1344+
agent_name = reg["name"]
1345+
1346+
lines = [
1347+
f"# {agent_name} — x402 Service Catalog\n",
1348+
"",
1349+
"> This document lists all payment-gated services on this node.",
1350+
"> Payment uses the [x402 protocol](https://www.x402.org/) with USDC stablecoin.",
1351+
"> For machine-readable agent identity, see [/.well-known/agent-registration.json](/.well-known/agent-registration.json).",
1352+
"",
1353+
]
1354+
1355+
if not ready:
1356+
lines.append("**No services currently available.**\n")
1357+
return "\n".join(lines)
1358+
1359+
lines.append("## Services\n")
1360+
lines.append("| Service | Type | Model | Price | Endpoint |")
1361+
lines.append("|---------|------|-------|-------|----------|")
1362+
for item in ready:
1363+
spec = item.get("spec", {})
1364+
name = item["metadata"]["name"]
1365+
offer_type = spec.get("type", "http")
1366+
model_name = spec.get("model", {}).get("name", "—")
1367+
path = spec.get("path", f"/services/{name}")
1368+
price_desc = describe_price(spec)
1369+
lines.append(f"| [{name}](#{name}) | {offer_type} | {model_name} | {price_desc} | `{base_url}{path}` |")
1370+
lines.append("")
1371+
1372+
lines.append("## How to Pay (x402 Protocol)\n")
1373+
lines.append("1. **Send a normal HTTP request** to the service endpoint")
1374+
lines.append("2. **Receive HTTP 402** with `X-Payment` response header containing JSON pricing:")
1375+
lines.append(" ```json")
1376+
lines.append(' {"x402Version":1,"schemes":[{"scheme":"exact","network":"...","maxAmountRequired":"...","payTo":"0x...","extra":{"name":"USDC","version":"2"}}]}')
1377+
lines.append(" ```")
1378+
lines.append("3. **Sign an ERC-3009 `transferWithAuthorization`** for USDC on the specified network:")
1379+
lines.append(" - `from`: your wallet address")
1380+
lines.append(" - `to`: the `payTo` address from the 402 response")
1381+
lines.append(" - `value`: the `maxAmountRequired` (in smallest units, 6 decimals)")
1382+
lines.append(" - `validAfter`: 0")
1383+
lines.append(" - `validBefore`: current timestamp + timeout")
1384+
lines.append(" - `nonce`: random 32 bytes")
1385+
lines.append("4. **Retry the original request** with `X-Payment` header containing your signed authorization")
1386+
lines.append("5. **Receive 200** with the actual service response")
1387+
lines.append("")
1388+
lines.append("### Quick Example (curl)\n")
1389+
lines.append("```bash")
1390+
lines.append("# Step 1: Probe for pricing")
1391+
first_spec = ready[0].get("spec", {})
1392+
first_path = first_spec.get("path", f"/services/{ready[0]['metadata']['name']}")
1393+
lines.append(f'curl -s -o /dev/null -w "%{{http_code}}" {base_url}{first_path}/v1/chat/completions')
1394+
lines.append("# Returns: 402")
1395+
lines.append("")
1396+
lines.append("# Step 2: Get pricing details")
1397+
lines.append(f'curl -sI {base_url}{first_path}/v1/chat/completions | grep X-Payment')
1398+
lines.append("```")
1399+
lines.append("")
1400+
lines.append("For programmatic payment, use [x402-go](https://github.com/coinbase/x402/tree/main/go), [x402-js](https://github.com/coinbase/x402/tree/main/typescript), or sign ERC-3009 directly with ethers/viem/web3.py.")
1401+
lines.append("")
1402+
1403+
lines.append("## Service Details\n")
1404+
for item in ready:
1405+
spec = item.get("spec", {})
1406+
name = item["metadata"]["name"]
1407+
offer_type = spec.get("type", "http")
1408+
model_name = spec.get("model", {}).get("name")
1409+
path = spec.get("path", f"/services/{name}")
1410+
registration = spec.get("registration", {})
1411+
default_desc = f"x402 payment-gated {offer_type} service"
1412+
if model_name:
1413+
default_desc = f"{model_name} inference via x402 micropayments"
1414+
1415+
lines.append(f"### {name}\n")
1416+
lines.append(f"- **Endpoint**: `{base_url}{path}`")
1417+
lines.append(f"- **Type**: {offer_type}")
1418+
if model_name:
1419+
lines.append(f"- **Model**: {model_name}")
1420+
lines.append(f"- **Price**: {describe_price(spec)}")
1421+
lines.append(f"- **Pay To**: `{get_pay_to(spec)}`")
1422+
lines.append(f"- **Network**: {get_network(spec)}")
1423+
lines.append(f"- **Description**: {registration.get('description', default_desc)}")
1424+
if offer_type == "inference" and model_name:
1425+
lines.append(f"\n**OpenAI-compatible endpoint**: `POST {base_url}{path}/v1/chat/completions`")
1426+
lines.append("```json")
1427+
lines.append('{')
1428+
lines.append(f' "model": "{model_name}",')
1429+
lines.append(' "messages": [{"role": "user", "content": "Hello"}]')
1430+
lines.append('}')
1431+
lines.append("```")
1432+
lines.append("")
1433+
1434+
lines.append("## USDC Contract Addresses\n")
1435+
lines.append("| Network | Address |")
1436+
lines.append("|---------|---------|")
1437+
lines.append("| Base Sepolia | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` |")
1438+
lines.append("| Base Mainnet | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` |")
1439+
lines.append("")
1440+
lines.append("## Links\n")
1441+
lines.append(f"- [Agent Registration](/.well-known/agent-registration.json)")
1442+
lines.append("- [x402 Protocol](https://www.x402.org/)")
1443+
lines.append("- [ERC-3009 (transferWithAuthorization)](https://eips.ethereum.org/EIPS/eip-3009)")
1444+
lines.append("- [ERC-8004 (Agent Identity)](https://eips.ethereum.org/EIPS/eip-8004)")
1445+
lines.append("")
1446+
1447+
return "\n".join(lines)
1448+
1449+
1450+
def _publish_skill_md(items, token, ssl_ctx):
1451+
"""Publish the /skill.md aggregate endpoint.
1452+
1453+
Creates four resources (no ownerReferences — aggregate, not per-offer):
1454+
1. ConfigMap obol-skill-md — markdown content + httpd.conf
1455+
2. Deployment obol-skill-md — busybox httpd serving the ConfigMap
1456+
3. Service obol-skill-md — ClusterIP targeting the deployment
1457+
4. HTTPRoute obol-skill-md-route — routes /skill.md to the Service
1458+
"""
1459+
import hashlib
1460+
1461+
base_url = os.environ.get("AGENT_BASE_URL", "http://obol.stack:8080")
1462+
_, agent_ns = load_sa()
1463+
content = _build_skill_md(items, base_url)
1464+
content_hash = hashlib.md5(content.encode()).hexdigest()[:8]
1465+
1466+
cm_name = "obol-skill-md"
1467+
deploy_name = "obol-skill-md"
1468+
svc_name = "obol-skill-md"
1469+
route_name = "obol-skill-md-route"
1470+
labels = {"app": deploy_name, "obol.org/managed-by": "monetize"}
1471+
1472+
configmap = {
1473+
"apiVersion": "v1",
1474+
"kind": "ConfigMap",
1475+
"metadata": {"name": cm_name, "namespace": agent_ns, "labels": labels},
1476+
"data": {
1477+
"skill.md": content,
1478+
"httpd.conf": ".md:text/markdown\n",
1479+
},
1480+
}
1481+
_apply_resource(f"/api/v1/namespaces/{agent_ns}/configmaps", cm_name, configmap, token, ssl_ctx)
1482+
1483+
deployment = {
1484+
"apiVersion": "apps/v1",
1485+
"kind": "Deployment",
1486+
"metadata": {"name": deploy_name, "namespace": agent_ns, "labels": labels},
1487+
"spec": {
1488+
"replicas": 1,
1489+
"selector": {"matchLabels": labels},
1490+
"template": {
1491+
"metadata": {
1492+
"labels": labels,
1493+
"annotations": {"obol.org/content-hash": content_hash},
1494+
},
1495+
"spec": {
1496+
"containers": [
1497+
{
1498+
"name": "httpd",
1499+
"image": "busybox:1.36",
1500+
"command": ["httpd", "-f", "-p", "8080", "-h", "/www"],
1501+
"ports": [{"containerPort": 8080}],
1502+
"volumeMounts": [
1503+
{"name": "content", "mountPath": "/www", "readOnly": True},
1504+
{"name": "httpdconf", "mountPath": "/etc/httpd.conf", "subPath": "httpd.conf", "readOnly": True},
1505+
],
1506+
"resources": {
1507+
"requests": {"cpu": "5m", "memory": "8Mi"},
1508+
"limits": {"cpu": "50m", "memory": "32Mi"},
1509+
},
1510+
}
1511+
],
1512+
"volumes": [
1513+
{
1514+
"name": "content",
1515+
"configMap": {
1516+
"name": cm_name,
1517+
"items": [{"key": "skill.md", "path": "skill.md"}],
1518+
},
1519+
},
1520+
{
1521+
"name": "httpdconf",
1522+
"configMap": {
1523+
"name": cm_name,
1524+
"items": [{"key": "httpd.conf", "path": "httpd.conf"}],
1525+
},
1526+
},
1527+
],
1528+
},
1529+
},
1530+
},
1531+
}
1532+
_apply_resource(f"/apis/apps/v1/namespaces/{agent_ns}/deployments", deploy_name, deployment, token, ssl_ctx)
1533+
1534+
service = {
1535+
"apiVersion": "v1",
1536+
"kind": "Service",
1537+
"metadata": {"name": svc_name, "namespace": agent_ns, "labels": labels},
1538+
"spec": {
1539+
"type": "ClusterIP",
1540+
"selector": labels,
1541+
"ports": [{"port": 8080, "targetPort": 8080, "protocol": "TCP"}],
1542+
},
1543+
}
1544+
_apply_resource(f"/api/v1/namespaces/{agent_ns}/services", svc_name, service, token, ssl_ctx)
1545+
1546+
httproute = {
1547+
"apiVersion": "gateway.networking.k8s.io/v1",
1548+
"kind": "HTTPRoute",
1549+
"metadata": {"name": route_name, "namespace": agent_ns},
1550+
"spec": {
1551+
"parentRefs": [
1552+
{"name": "traefik-gateway", "namespace": "traefik", "sectionName": "web"}
1553+
],
1554+
"rules": [
1555+
{
1556+
"matches": [{"path": {"type": "Exact", "value": "/skill.md"}}],
1557+
"backendRefs": [{"name": svc_name, "namespace": agent_ns, "port": 8080}],
1558+
}
1559+
],
1560+
},
1561+
}
1562+
_apply_resource(
1563+
f"/apis/gateway.networking.k8s.io/v1/namespaces/{agent_ns}/httproutes",
1564+
route_name, httproute, token, ssl_ctx,
1565+
)
1566+
1567+
ready_count = sum(1 for i in items if is_condition_true(i.get("status", {}).get("conditions", []), "Ready"))
1568+
print(f" Published /skill.md ({ready_count} service(s))")
1569+
1570+
13321571
def reconcile(ns, name, token, ssl_ctx):
13331572
"""Reconcile a single ServiceOffer through all stages."""
13341573
path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{CRD_PLURAL}/{name}"
@@ -1608,6 +1847,10 @@ def cmd_process(ns, name, all_offers, quick, token, ssl_ctx):
16081847

16091848
if not items:
16101849
print("READY: 0/0 offers" if quick else "HEARTBEAT_OK: No ServiceOffers found")
1850+
try:
1851+
_publish_skill_md([], token, ssl_ctx)
1852+
except Exception as e:
1853+
print(f" Warning: skill.md publish failed: {e}", file=sys.stderr)
16111854
return
16121855

16131856
pending = []
@@ -1618,6 +1861,10 @@ def cmd_process(ns, name, all_offers, quick, token, ssl_ctx):
16181861

16191862
if not pending:
16201863
print(f"READY: {len(items)}/{len(items)} offers" if quick else "HEARTBEAT_OK: All offers are Ready")
1864+
try:
1865+
_publish_skill_md(items, token, ssl_ctx)
1866+
except Exception as e:
1867+
print(f" Warning: skill.md publish failed: {e}", file=sys.stderr)
16211868
return
16221869

16231870
if quick:
@@ -1651,6 +1898,14 @@ def cmd_process(ns, name, all_offers, quick, token, ssl_ctx):
16511898
reconcile(item_ns, item_name, token, ssl_ctx)
16521899
except Exception as e:
16531900
print(f" Error reconciling {item_ns}/{item_name}: {e}", file=sys.stderr)
1901+
1902+
# Regenerate /skill.md from current state of all offers.
1903+
try:
1904+
all_path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/{CRD_PLURAL}"
1905+
all_data = api_get(all_path, token, ssl_ctx)
1906+
_publish_skill_md(all_data.get("items", []), token, ssl_ctx)
1907+
except Exception as e:
1908+
print(f" Warning: skill.md publish failed: {e}", file=sys.stderr)
16541909
else:
16551910
if not ns or not name:
16561911
print("Error: --namespace and name are required (or use --all)", file=sys.stderr)

0 commit comments

Comments
 (0)