feat(agent-call): add contract-driven mock executor
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
groups:
|
||||
- name: agent-call
|
||||
rules:
|
||||
- alert: AgentCallOutboxStalled
|
||||
expr: agent_call_outbox_pending > 0
|
||||
for: 2m
|
||||
labels: {severity: warning}
|
||||
annotations:
|
||||
summary: agent-call outbox has pending events
|
||||
- alert: AgentCallDeadLetters
|
||||
expr: increase(agent_call_dead_letters_total[5m]) > 0
|
||||
for: 1m
|
||||
labels: {severity: critical}
|
||||
annotations:
|
||||
summary: agent-call rejected or malformed messages entered the DLQ
|
||||
- alert: AgentCallNotMockSafe
|
||||
expr: agent_call_mode_info{mode="mock"} != 1
|
||||
for: 1m
|
||||
labels: {severity: critical}
|
||||
annotations:
|
||||
summary: provider mode changed; verify production admission gates
|
||||
@@ -11,5 +11,6 @@
|
||||
"key_pair_name": "",
|
||||
"spot_price_limit": null,
|
||||
"system_disk_category": "cloud_essd",
|
||||
"system_disk_performance_level": "PL1",
|
||||
"system_disk_gib": 40
|
||||
}
|
||||
|
||||
+18
-1
@@ -2,6 +2,7 @@
|
||||
"""Read-only by default. Reuse/prepare the fixed Beijing host via the aliyun CLI."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
@@ -230,7 +231,7 @@ def create_params(cfg):
|
||||
raise CloudError(
|
||||
"system_disk_gib must be within the approved 40–200 GiB safety bound"
|
||||
)
|
||||
return {
|
||||
params = {
|
||||
"ImageId": cfg["image_id"],
|
||||
"InstanceType": cfg["instance_type"],
|
||||
"VSwitchId": cfg["vswitch_id"],
|
||||
@@ -244,9 +245,25 @@ def create_params(cfg):
|
||||
"SpotPriceLimit": price,
|
||||
"SystemDisk.Category": cfg.get("system_disk_category", "cloud_essd"),
|
||||
"SystemDisk.Size": disk,
|
||||
"SystemDisk.PerformanceLevel": cfg.get("system_disk_performance_level", "PL1"),
|
||||
"Tag.1.Key": "project",
|
||||
"Tag.1.Value": cfg["project_tag"],
|
||||
}
|
||||
user_data_file = cfg.get("user_data_file")
|
||||
if user_data_file is not None:
|
||||
if not isinstance(user_data_file, str) or not user_data_file.strip():
|
||||
raise CloudError("user_data_file must be a non-empty local file path")
|
||||
path = Path(user_data_file)
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise CloudError("user_data_file must be an existing non-symlink file")
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise CloudError("user_data_file cannot be read") from exc
|
||||
if len(content) > 16 * 1024:
|
||||
raise CloudError("user_data_file exceeds the ECS 16 KiB limit")
|
||||
params["UserData"] = base64.b64encode(content).decode("ascii")
|
||||
return params
|
||||
|
||||
|
||||
def save_state(path, data):
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def copy_database(source: str, destination: str) -> None:
|
||||
source_path = Path(source)
|
||||
destination_path = Path(destination)
|
||||
if not source_path.is_file():
|
||||
raise FileNotFoundError(source)
|
||||
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source_db = sqlite3.connect(source_path)
|
||||
destination_db = sqlite3.connect(destination_path)
|
||||
try:
|
||||
source_db.backup(destination_db)
|
||||
destination_db.commit()
|
||||
finally:
|
||||
destination_db.close()
|
||||
source_db.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="offline SQLite backup/restore for agent-call"
|
||||
)
|
||||
parser.add_argument("action", choices=("backup", "restore"))
|
||||
parser.add_argument("source")
|
||||
parser.add_argument("destination")
|
||||
args = parser.parse_args()
|
||||
copy_database(args.source, args.destination)
|
||||
print(f"{args.action} complete")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
_core = importlib.import_module("agent_call.core")
|
||||
queue_name = _core.queue_name
|
||||
routing_key = _core.routing_key
|
||||
|
||||
|
||||
def check(profile_path: str | Path) -> list[str]:
|
||||
path = Path(profile_path)
|
||||
try:
|
||||
profile = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
return [f"profile load failed: {type(exc).__name__}"]
|
||||
problems: list[str] = []
|
||||
if profile.get("mode") != "mock":
|
||||
problems.append("mock profile must declare mode=mock")
|
||||
for component, provider in profile.get("provider_modes", {}).items():
|
||||
if component in {"llm", "tts", "asr", "sip", "oss"} and provider == "real":
|
||||
problems.append(f"mock profile enables real provider: {component}")
|
||||
for tenant in profile.get("tenants", []):
|
||||
try:
|
||||
route = routing_key(tenant["tenant_key"])
|
||||
queue_name(tenant["tenant_key"])
|
||||
except Exception as exc:
|
||||
problems.append(f"tenant route invalid: {type(exc).__name__}")
|
||||
continue
|
||||
if len(route.encode("utf-8")) > 255:
|
||||
problems.append("tenant route exceeds broker limit")
|
||||
if not profile.get("cells"):
|
||||
problems.append("no execution cells configured")
|
||||
return problems
|
||||
|
||||
|
||||
def main() -> int:
|
||||
problems = check(
|
||||
sys.argv[1] if len(sys.argv) > 1 else "docs/contracts/mock-profile.json"
|
||||
)
|
||||
if problems:
|
||||
for problem in problems:
|
||||
print(f"ERROR {problem}")
|
||||
return 1
|
||||
print("mock profile clean")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "agent-call overview",
|
||||
"schemaVersion": 39,
|
||||
"panels": [
|
||||
{"type": "stat", "title": "Active calls", "targets": [{"expr": "agent_call_active_calls"}]},
|
||||
{"type": "stat", "title": "Waiting commands", "targets": [{"expr": "agent_call_waiting_commands"}]},
|
||||
{"type": "stat", "title": "Outbox pending", "targets": [{"expr": "agent_call_outbox_pending"}]},
|
||||
{"type": "stat", "title": "Dead letters", "targets": [{"expr": "agent_call_dead_letters_total"}], "transparent": false}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
_core = importlib.import_module("agent_call.core")
|
||||
Store = _core.Store
|
||||
|
||||
|
||||
def migrate(path: str) -> int:
|
||||
store = Store(path)
|
||||
row = store.one("SELECT MAX(version) AS version FROM schema_migrations")
|
||||
print(f"schema_version={row['version']}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="apply idempotent agent-call SQLite migrations"
|
||||
)
|
||||
parser.add_argument("database")
|
||||
args = parser.parse_args()
|
||||
return migrate(args.database)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,9 @@
|
||||
# Synthetic local values only. Never use this file for production credentials.
|
||||
RABBITMQ_DEFAULT_USER=agent_call_mock
|
||||
RABBITMQ_DEFAULT_PASS=replace-before-start
|
||||
RABBITMQ_URL=amqp://agent_call_mock:replace-before-start@rabbitmq:5672/%2f
|
||||
# JSON map: token -> tenant IDs and scopes. Inject through a secret manager in real deployments.
|
||||
HTTP_TOKENS={"local":{"tenant_ids":["tenant-demo"],"scopes":["outbound.read","outbound.control","recording.upload","recording.complete"]}}
|
||||
AGENT_CALL_MODE=mock
|
||||
BROKER_MODE=rabbit
|
||||
AGENT_CALL_PORT=18080
|
||||
@@ -0,0 +1,9 @@
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: agent-call
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["agent-call:8080"]
|
||||
+47
-12
@@ -49,6 +49,21 @@ def endpoint(name, data, env):
|
||||
if not isinstance(registration, bool) or registration and mode != "digest":
|
||||
raise ValueError("registration requires explicit digest authentication")
|
||||
text = f"[{name}]\ntype=endpoint\ntransport=transport-udp\ncontext=deny-inbound\ndisallow=all\nallow=ulaw\ndirect_media=no\nrtp_symmetric=yes\nforce_rport=yes\nrewrite_contact=yes\naors={name}-aor\n"
|
||||
from_user = data.get("from_user")
|
||||
if from_user is not None:
|
||||
if not isinstance(from_user, str) or not re.fullmatch(
|
||||
r"[A-Za-z0-9_.+\-]+", from_user
|
||||
):
|
||||
raise ValueError(f"{name}.from_user must be a plain caller identifier")
|
||||
text += f"from_user={from_user}\n"
|
||||
display_identity = data.get("caller_id")
|
||||
if display_identity is not None:
|
||||
display_identity = scalar(display_identity, name + ".caller_id")
|
||||
if not re.fullmatch(
|
||||
r"[A-Za-z0-9_.+\- ]+(?:<[A-Za-z0-9_.+\- ]+>)?", display_identity
|
||||
):
|
||||
raise ValueError(f"{name}.caller_id contains unsupported characters")
|
||||
text += f"callerid={display_identity}\n"
|
||||
auth = ""
|
||||
if mode == "digest":
|
||||
user = scalar(data.get("username"), name + ".username")
|
||||
@@ -56,7 +71,9 @@ def endpoint(name, data, env):
|
||||
raise ValueError("SIP username must be a plain user identifier")
|
||||
password_key = "SIP_" + name.removeprefix("provider-").upper() + "_PASSWORD"
|
||||
password = scalar(env.get(password_key), password_key, secret=True)
|
||||
text += f"outbound_auth={name}-auth\nfrom_user={user}\n"
|
||||
text += f"outbound_auth={name}-auth\n"
|
||||
if from_user is None:
|
||||
text += f"from_user={user}\n"
|
||||
auth = f"\n[{name}-auth]\ntype=auth\nauth_type=userpass\nusername={user}\npassword={password}\n"
|
||||
if registration:
|
||||
auth += f"\n[{name}-registration]\ntype=registration\ntransport=transport-udp\noutbound_auth={name}-auth\nserver_uri=sip:{host}:{port}\nclient_uri=sip:{user}@{host}:{port}\nretry_interval=60\n"
|
||||
@@ -89,22 +106,40 @@ def render(cfg, env):
|
||||
password = scalar(env.get("ARI_PASSWORD"), "ARI_PASSWORD", secret=True)
|
||||
if len(password) < 32:
|
||||
raise ValueError("ARI_PASSWORD must have at least 32 characters")
|
||||
primary = endpoint("provider-primary", cfg.get("primary", {}), env)
|
||||
backup = endpoint("provider-backup", cfg.get("backup", {}), env)
|
||||
if (cfg["primary"]["host"], cfg["primary"].get("port", 5060)) == (
|
||||
cfg["backup"]["host"],
|
||||
cfg["backup"].get("port", 5060),
|
||||
):
|
||||
raise ValueError(
|
||||
"primary and backup targets must be distinct; shared failure domains still require validation"
|
||||
)
|
||||
primary_data = cfg.get("primary", {})
|
||||
primary = endpoint("provider-primary", primary_data, env)
|
||||
backup_data = cfg.get("backup")
|
||||
if backup_data is not None and not isinstance(backup_data, dict):
|
||||
raise ValueError("backup must be an object when configured")
|
||||
sections = [primary]
|
||||
if backup_data:
|
||||
backup = endpoint("provider-backup", backup_data, env)
|
||||
if (primary_data["host"], primary_data.get("port", 5060)) == (
|
||||
backup_data["host"],
|
||||
backup_data.get("port", 5060),
|
||||
):
|
||||
raise ValueError(
|
||||
"primary and backup targets must be distinct; shared failure domains still require validation"
|
||||
)
|
||||
sections.append(backup)
|
||||
transport = f"[global]\ntype=global\nuser_agent=agent-call\n\n[transport-udp]\ntype=transport\nprotocol=udp\nbind=0.0.0.0:5060\nlocal_net={network}\nexternal_signaling_address={PUBLIC_IP}\nexternal_media_address={PUBLIC_IP}\n\n"
|
||||
caller = primary_data.get("caller_id") or primary_data.get("from_user")
|
||||
extensions = "[deny-inbound]\nexten => s,1,Hangup()\nexten => _.,1,Hangup()\n"
|
||||
if caller:
|
||||
caller = scalar(caller, "primary.caller_id")
|
||||
extensions += (
|
||||
"\n[outbound]\n"
|
||||
"exten => _X.,1,NoOp(agent-call outbound)\n"
|
||||
f" same => n,Set(CALLERID(all)={caller})\n"
|
||||
" same => n,Dial(PJSIP/${EXTEN}@provider-primary,60)\n"
|
||||
" same => n,Hangup()\n"
|
||||
)
|
||||
return {
|
||||
"http.conf": f"[general]\nenabled=yes\nbindaddr={bind}\nbindport=8088\n",
|
||||
"ari.conf": f"[general]\nenabled=yes\npretty=no\n\n[outbound]\ntype=user\nread_only=no\npassword={password}\n",
|
||||
"pjsip.conf": transport + primary + "\n" + backup,
|
||||
"pjsip.conf": transport + "\n".join(sections),
|
||||
"rtp.conf": "[general]\nrtpstart=10000\nrtpend=10800\nstrictrtp=yes\n",
|
||||
"extensions.conf": "[deny-inbound]\nexten => s,1,Hangup()\nexten => _.,1,Hangup()\n",
|
||||
"extensions.conf": extensions,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user