Files
crank/scripts/authenticated-product-smoke.py
T

750 lines
24 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import http.cookiejar
import json
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
DEFAULT_WORKSPACE_ID = "ws_default"
DEFAULT_WORKSPACE_SLUG = "default"
DEFAULT_INTERNAL_UPSTREAM = "http://admin-api:3001"
MCP_PROTOCOL_VERSION = "2025-11-25"
class SmokeError(RuntimeError):
pass
def safe_error(stage: str, code: str, status: int | None = None) -> SmokeError:
message = f"stage={stage[:64]} code={code[:96]}"
if status is not None:
message += f" status={status}"
return SmokeError(message[:256])
def require_object(value: Any, stage: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise safe_error(stage, "invalid_response")
return value
@dataclass
class JsonResponse:
status: int
headers: Any
body: Any
def build_operation_payload(name: str, upstream_base_url: str) -> dict[str, Any]:
return {
"name": name,
"display_name": "Internal Health Smoke",
"category": "smoke",
"protocol": "rest",
"security_level": "standard",
"target": {
"kind": "rest",
"base_url": upstream_base_url.rstrip("/"),
"method": "GET",
"path_template": "/health",
"static_headers": {},
},
"input_schema": {
"type": "object",
"required": True,
"fields": {
"probe": {
"type": "string",
"required": True,
"description": "Smoke probe value.",
},
},
},
"output_schema": {
"type": "object",
"required": True,
"fields": {
"status": {
"type": "string",
"required": True,
"description": "Health status returned by admin-api.",
},
},
},
"input_mapping": {
"rules": [
{
"source": "$.mcp.probe",
"target": "$.request.query.probe",
"required": True,
},
],
},
"output_mapping": {
"rules": [
{
"source": "$.response.body.status",
"target": "$.output.status",
"required": True,
},
],
},
"execution_config": {
"timeout_ms": 5000,
"headers": {},
},
"tool_description": {
"title": "Check internal service health",
"description": "Checks the internal Crank admin-api health endpoint. Use this only for deployment smoke verification.",
"tags": ["smoke", "health"],
"examples": [{"input": {"probe": "ok"}}],
},
}
def agent_mcp_url(
base_url: str,
workspace_slug: str,
agent_slug: str,
path_prefix: str = "/mcp",
) -> str:
normalized_prefix = "/" + path_prefix.strip("/") if path_prefix.strip("/") else ""
return f"{base_url.rstrip('/')}{normalized_prefix}/v1/{workspace_slug}/{agent_slug}"
def tools_call_payload(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
return {
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": arguments,
},
}
class Client:
def __init__(self, base_url: str, timeout_seconds: int) -> None:
self.base_url = base_url.rstrip("/")
self.timeout_seconds = timeout_seconds
cookie_jar = http.cookiejar.CookieJar()
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(cookie_jar)
)
def request_json(
self,
method: str,
path_or_url: str,
payload: dict[str, Any] | list[Any] | None = None,
headers: dict[str, str] | None = None,
expected: tuple[int, ...] = (200,),
) -> JsonResponse:
url = (
path_or_url
if path_or_url.startswith("http://") or path_or_url.startswith("https://")
else f"{self.base_url}{path_or_url}"
)
data = None
request_headers = {"Accept": "application/json"}
if headers:
request_headers.update(headers)
if payload is not None:
data = json.dumps(payload).encode("utf-8")
request_headers["Content-Type"] = "application/json"
request = urllib.request.Request(
url,
data=data,
headers=request_headers,
method=method,
)
try:
response = self.opener.open(request, timeout=self.timeout_seconds)
status = response.status
raw_bytes = response.read(1_048_577)
if len(raw_bytes) > 1_048_576:
raise safe_error("http", "response_too_large", status=status)
try:
raw = raw_bytes.decode("utf-8")
body = json.loads(raw) if raw else None
except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError) as error:
raise safe_error("http", "invalid_json", status=status) from error
headers_obj = response.headers
except urllib.error.HTTPError as error:
status = error.code
raw = error.read(1_048_577)
if len(raw) > 1_048_576:
raise safe_error("http", "response_too_large", status=status)
try:
body = json.loads(raw.decode("utf-8")) if raw else None
except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError) as parse_error:
raise safe_error("http", "invalid_json", status=status) from parse_error
headers_obj = error.headers
except (urllib.error.URLError, TimeoutError, OSError) as error:
raise safe_error("http", "request_failed") from error
if status not in expected:
raise safe_error("http", "unexpected_status", status=status)
return JsonResponse(status=status, headers=headers_obj, body=body)
def admin_path(workspace_id: str, suffix: str) -> str:
return f"/api/admin/workspaces/{workspace_id}{suffix}"
def login(client: Client, email: str, password: str) -> None:
client.request_json(
"POST",
"/api/auth/login",
{"email": email, "password": password},
expected=(200,),
)
def resolve_workspace(
client: Client,
fallback_workspace_id: str,
fallback_workspace_slug: str,
) -> tuple[str, str]:
session = require_object(client.request_json("GET", "/api/auth/session").body, "workspace")
memberships = session.get("memberships") or []
if not isinstance(memberships, list) or any(not isinstance(item, dict) for item in memberships):
raise safe_error("workspace", "invalid_response")
current_workspace_id = session.get("current_workspace_id") or fallback_workspace_id
membership = next(
(
item
for item in memberships
if isinstance(item.get("workspace"), dict) and item["workspace"].get("id") == current_workspace_id
),
None,
)
if not membership and memberships:
membership = memberships[0]
workspace = membership.get("workspace", {}) if membership else {}
if not isinstance(workspace, dict):
raise safe_error("workspace", "invalid_response")
workspace_id = workspace.get("id") or current_workspace_id
workspace_slug = workspace.get("slug") or fallback_workspace_slug
if not workspace_id:
raise safe_error("workspace", "missing_id")
if not workspace_slug:
raise safe_error("workspace", "missing_slug")
return workspace_id, workspace_slug
def create_operation(
client: Client,
workspace_id: str,
operation_name: str,
internal_upstream: str,
) -> tuple[str, int]:
created = client.request_json(
"POST",
admin_path(workspace_id, "/operations"),
build_operation_payload(operation_name, internal_upstream),
).body
try:
return str(created["operation_id"]), int(created["version"])
except (KeyError, TypeError, ValueError) as error:
raise safe_error("operation_create", "invalid_response") from error
def run_operation_test(
client: Client,
workspace_id: str,
operation_id: str,
operation_version: int,
) -> None:
result = client.request_json(
"POST",
admin_path(workspace_id, f"/operations/{operation_id}/test-runs"),
{"version": operation_version, "input": {"probe": "ok"}},
).body
if not isinstance(result, dict) or result.get("ok") is not True:
raise safe_error("operation_test", "outcome_not_ok")
def operation_etag(client: Client, workspace_id: str, operation_id: str) -> str:
response = client.request_json(
"GET",
admin_path(workspace_id, f"/operations/{operation_id}"),
)
etag = response.headers.get("ETag") if response.headers is not None else None
if not isinstance(etag, str) or len(etag) > 128 or not etag.startswith('"') or not etag.endswith('"'):
raise safe_error("operation_precondition", "invalid_response")
return etag
def publish_operation(
client: Client,
workspace_id: str,
operation_id: str,
operation_version: int,
) -> int:
published = client.request_json(
"POST",
admin_path(workspace_id, f"/operations/{operation_id}/publish"),
{"version": operation_version},
headers={"If-Match": operation_etag(client, workspace_id, operation_id)},
).body
try:
published_version = int(published["published_version"])
except (KeyError, TypeError, ValueError) as error:
raise safe_error("operation_publish", "invalid_response") from error
if published_version != operation_version:
raise safe_error("operation_publish", "version_mismatch")
return published_version
def create_agent(client: Client, workspace_id: str, agent_slug: str) -> tuple[str, int]:
created = client.request_json(
"POST",
admin_path(workspace_id, "/agents"),
{
"slug": agent_slug,
"display_name": "Internal Health Smoke",
"description": "Deployment smoke agent.",
"instructions": {},
"tool_selection_policy": {},
},
).body
try:
return str(created["agent_id"]), int(created["version"])
except (KeyError, TypeError, ValueError) as error:
raise safe_error("agent_create", "invalid_response") from error
def edit_and_archive_operation(
client: Client,
workspace_id: str,
operation_id: str,
operation_name: str,
internal_upstream: str,
) -> int:
payload = build_operation_payload(operation_name, internal_upstream)
payload.pop("name", None)
payload.pop("protocol", None)
payload["display_name"] = "Internal Health Smoke Edited Draft"
updated = client.request_json(
"PATCH",
admin_path(workspace_id, f"/operations/{operation_id}"),
payload,
headers={"If-Match": operation_etag(client, workspace_id, operation_id)},
).body
try:
draft_version = int(updated["version"])
except (KeyError, TypeError, ValueError) as error:
raise safe_error("operation_edit", "invalid_response") from error
client.request_json(
"POST",
admin_path(workspace_id, f"/operations/{operation_id}/archive"),
headers={"If-Match": operation_etag(client, workspace_id, operation_id)},
)
return draft_version
def bind_and_publish_agent(
client: Client,
workspace_id: str,
agent_id: str,
agent_version: int,
operation_id: str,
operation_version: int,
tool_name: str,
) -> int:
client.request_json(
"POST",
admin_path(workspace_id, f"/agents/{agent_id}/bindings"),
[
{
"operation_id": operation_id,
"operation_version": operation_version,
"tool_name": tool_name,
"tool_title": "Check internal service health",
"tool_description_override": None,
"enabled": True,
}
],
)
published = client.request_json(
"POST",
admin_path(workspace_id, f"/agents/{agent_id}/publish"),
{"version": agent_version},
).body
try:
published_version = int(published["published_version"])
except (KeyError, TypeError, ValueError) as error:
raise safe_error("agent_publish", "invalid_response") from error
if published_version != agent_version:
raise safe_error("agent_publish", "version_mismatch")
return published_version
def create_agent_key(client: Client, workspace_id: str, agent_id: str) -> tuple[str, str]:
created = client.request_json(
"POST",
admin_path(workspace_id, f"/agents/{agent_id}/platform-api-keys"),
{"name": f"smoke-key-{int(time.time())}", "scopes": ["read", "write"]},
).body
try:
secret = created["secret"]
key_id = created["api_key"]["api_key"]["id"]
except (KeyError, TypeError) as error:
raise safe_error("key_create", "invalid_response") from error
if not nonempty_string(secret) or not nonempty_string(key_id):
raise safe_error("key_create", "invalid_response")
return secret, key_id
def nonempty_string(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def cleanup_smoke_assets(
client: Client,
workspace_id: str,
operation_id: str | None,
agent_id: str | None,
key_id: str | None,
) -> None:
if os.environ.get("CRANK_PRODUCT_SMOKE_KEEP_ASSETS") == "1":
print("cleanup skipped: CRANK_PRODUCT_SMOKE_KEEP_ASSETS=1")
return
if agent_id and key_id:
try:
client.request_json(
"DELETE",
admin_path(workspace_id, f"/agents/{agent_id}/platform-api-keys/{key_id}"),
expected=(200, 204, 404),
)
except SmokeError as error:
print(f"cleanup warning: key delete failed: {error}")
if agent_id:
try:
client.request_json(
"DELETE",
admin_path(workspace_id, f"/agents/{agent_id}"),
expected=(200, 404),
)
except SmokeError as error:
print(f"cleanup warning: agent delete failed: {error}")
if operation_id:
try:
client.request_json(
"DELETE",
admin_path(workspace_id, f"/operations/{operation_id}"),
headers={"If-Match": operation_etag(client, workspace_id, operation_id)},
expected=(200, 404),
)
except SmokeError:
try:
client.request_json(
"POST",
admin_path(workspace_id, f"/operations/{operation_id}/archive"),
headers={"If-Match": operation_etag(client, workspace_id, operation_id)},
expected=(200, 404),
)
except SmokeError as error:
print(f"cleanup warning: operation cleanup failed: {error}")
def initialize_mcp_session(client: Client, mcp_url: str, api_key: str) -> str:
initialized = client.request_json(
"POST",
mcp_url,
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"protocolVersion": MCP_PROTOCOL_VERSION},
},
headers={
"Accept": "application/json, text/event-stream",
"Authorization": f"Bearer {api_key}",
},
)
session_id = initialized.headers.get("MCP-Session-Id")
if not session_id:
raise safe_error("mcp_initialize", "missing_session_id")
client.request_json(
"POST",
mcp_url,
{
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": {},
},
headers={
"Accept": "application/json, text/event-stream",
"Authorization": f"Bearer {api_key}",
"MCP-Session-Id": session_id,
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
},
expected=(202,),
)
return session_id
def terminate_mcp_session(client: Client, mcp_url: str, api_key: str, session_id: str) -> None:
client.request_json(
"DELETE",
mcp_url,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {api_key}",
"MCP-Session-Id": session_id,
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
},
expected=(204, 404),
)
def call_mcp(
client: Client,
mcp_url: str,
api_key: str,
session_id: str,
payload: dict[str, Any],
) -> Any:
return client.request_json(
"POST",
mcp_url,
payload,
headers={
"Accept": "application/json, text/event-stream",
"Authorization": f"Bearer {api_key}",
"MCP-Session-Id": session_id,
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
},
).body
def validate_tools_list(value: Any, expected_name: str) -> None:
payload = require_object(value, "mcp_tools_list")
result = payload.get("result")
tools = result.get("tools") if isinstance(result, dict) else None
if not isinstance(tools, list) or any(not isinstance(tool, dict) or not nonempty_string(tool.get("name")) for tool in tools):
raise safe_error("mcp_tools_list", "invalid_response")
if expected_name not in [tool["name"] for tool in tools]:
raise safe_error("mcp_tools_list", "published_tool_missing")
def validate_tool_call_result(value: Any) -> None:
payload = require_object(value, "mcp_tools_call")
result = payload.get("result")
if "error" in payload or not isinstance(result, dict) or result.get("isError") is not False:
raise safe_error("mcp_tools_call", "outcome_not_ok")
structured = result.get("structuredContent")
if not isinstance(structured, dict) or structured.get("status") != "ok":
raise safe_error("mcp_tools_call", "unexpected_output")
def build_safe_summary(
operation_id: str,
operation_version: int,
agent_id: str,
agent_revision: int,
) -> dict[str, Any]:
return {
"agent_id": agent_id[:128],
"agent_revision": agent_revision,
"command_id": "authenticated-product-smoke",
"operation_id": operation_id[:128],
"operation_version": operation_version,
"stages": [
"admin_test",
"operation_publish",
"agent_publish",
"mcp_list",
"mcp_call",
"operation_edit_archive",
"pinned_mcp_list_call",
],
"verdict": "pass",
}
def run(args: argparse.Namespace) -> None:
admin_email = os.environ.get("CRANK_STAGING_ADMIN_EMAIL")
admin_password = os.environ.get("CRANK_STAGING_ADMIN_PASSWORD")
if not admin_email:
raise safe_error("configuration", "missing_admin_email")
if not admin_password:
raise safe_error("configuration", "missing_admin_password")
timestamp = int(time.time())
operation_name = f"internal_health_smoke_{timestamp}"
agent_slug = f"health-smoke-{timestamp}"
client = Client(args.base_url, args.timeout_seconds)
operation_id = None
agent_id = None
key_id = None
api_key = None
mcp_url = None
session_id = None
print("authenticated product smoke: started")
login(client, admin_email, admin_password)
print("login: ok")
workspace_id, workspace_slug = resolve_workspace(
client,
args.workspace_id,
args.workspace_slug,
)
print(f"workspace: {workspace_id} / {workspace_slug}")
try:
operation_id, operation_version = create_operation(
client,
workspace_id,
operation_name,
args.internal_upstream,
)
print(f"operation created: {operation_id} version={operation_version}")
run_operation_test(client, workspace_id, operation_id, operation_version)
print("operation test: ok")
published_operation_version = publish_operation(
client, workspace_id, operation_id, operation_version
)
print(f"operation published: version={published_operation_version}")
agent_id, agent_version = create_agent(client, workspace_id, agent_slug)
published_agent_version = bind_and_publish_agent(
client,
workspace_id,
agent_id,
agent_version,
operation_id,
published_operation_version,
operation_name,
)
print(f"agent published: {agent_id} revision={published_agent_version}")
api_key, key_id = create_agent_key(client, workspace_id, agent_id)
mcp_url = agent_mcp_url(
args.mcp_base_url or args.base_url,
workspace_slug,
agent_slug,
args.mcp_path_prefix,
)
session_id = initialize_mcp_session(client, mcp_url, api_key)
print("mcp initialized: ok")
tools = call_mcp(
client,
mcp_url,
api_key,
session_id,
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
)
validate_tools_list(tools, operation_name)
print("tools/list: ok")
result = call_mcp(
client,
mcp_url,
api_key,
session_id,
tools_call_payload(operation_name, {"probe": "ok"}),
)
validate_tool_call_result(result)
print("tools/call: ok")
edited_draft_version = edit_and_archive_operation(
client,
workspace_id,
operation_id,
operation_name,
args.internal_upstream,
)
print(f"operation edited and archived: draft_version={edited_draft_version}")
tools = call_mcp(
client,
mcp_url,
api_key,
session_id,
{"jsonrpc": "2.0", "id": 4, "method": "tools/list", "params": {}},
)
validate_tools_list(tools, operation_name)
pinned_result = call_mcp(
client,
mcp_url,
api_key,
session_id,
tools_call_payload(operation_name, {"probe": "ok"}),
)
validate_tool_call_result(pinned_result)
print("pinned tools/list and tools/call after Operation archive: ok")
summary = build_safe_summary(
operation_id, published_operation_version, agent_id, published_agent_version
)
if args.summary_output:
try:
Path(args.summary_output).write_text(
json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
except OSError as error:
raise safe_error("summary", "write_failed") from error
print("authenticated product smoke completed")
finally:
if mcp_url and api_key and session_id:
try:
terminate_mcp_session(client, mcp_url, api_key, session_id)
except SmokeError as error:
print(f"cleanup warning: session termination failed: {error}")
cleanup_smoke_assets(client, workspace_id, operation_id, agent_id, key_id)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run deterministic authenticated product smoke against deployed Crank."
)
parser.add_argument("base_url", help="Public Crank base URL, for example https://crank.example.com")
parser.add_argument("--workspace-id", default=DEFAULT_WORKSPACE_ID)
parser.add_argument("--workspace-slug", default=DEFAULT_WORKSPACE_SLUG)
parser.add_argument("--internal-upstream", default=DEFAULT_INTERNAL_UPSTREAM)
parser.add_argument(
"--mcp-base-url",
help="Optional direct MCP base URL for a local split-port stack.",
)
parser.add_argument(
"--mcp-path-prefix",
default="/mcp",
help="MCP proxy prefix; use an empty value for a direct MCP listener.",
)
parser.add_argument("--summary-output")
parser.add_argument(
"--timeout-seconds",
type=int,
default=int(os.environ.get("SMOKE_TIMEOUT_SECONDS", "20")),
)
return parser.parse_args()
def main() -> int:
try:
run(parse_args())
except SmokeError as error:
print(f"smoke failed: {error}", flush=True)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())