@crvouga/dev-fleet 0.0.0-stage → 0.1.0
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.
- package/.mise/locks/aws-cdk/2.1146.1/aube-lock.yaml +24 -0
- package/.mise/locks/aws-cdk/2.1146.1/package.json +7 -0
- package/.mise/locks/azure-cli/2.91.0~a837e014/pyproject.toml +5 -0
- package/.mise/locks/azure-cli/2.91.0~a837e014/uv.lock +2794 -0
- package/.mise/locks/gemini/0.66.0~84aac8e6/aube-lock.yaml +123 -0
- package/.mise/locks/gemini/0.66.0~84aac8e6/package.json +12 -0
- package/.mise/locks/npm-devcontainers-cli/0.90.0/aube-lock.yaml +24 -0
- package/.mise/locks/npm-devcontainers-cli/0.90.0/package.json +7 -0
- package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/aube-lock.yaml +1432 -0
- package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/package.json +7 -0
- package/.mise/locks/npm-vercel/65.8.0/aube-lock.yaml +3595 -0
- package/.mise/locks/npm-vercel/65.8.0/package.json +7 -0
- package/.mise/locks/npm-wrangler/4.156.0/aube-lock.yaml +895 -0
- package/.mise/locks/npm-wrangler/4.156.0/package.json +7 -0
- package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/aube-lock.yaml +24 -0
- package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/package.json +7 -0
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/agents/antigravity-proxy.ts +3 -0
- package/agents/audit.md +89 -0
- package/agents/claude-proxy +5 -0
- package/agents/claude-proxy.ts +3 -0
- package/agents/codex-proxy.ts +3 -0
- package/agents/grok-proxy.ts +3 -0
- package/agents/managed-proxy.ts +55 -0
- package/agents/opencode-proxy.ts +3 -0
- package/agents/registry.json +209 -0
- package/agents/registry.schema.json +149 -0
- package/agents/setup.md +128 -0
- package/agents/t3.json +40 -0
- package/ansible/macos/manager.yml +30 -0
- package/ansible/macos/policy.json +17 -0
- package/ansible/monitoring/README.md +154 -0
- package/ansible/monitoring/agent-darwin.yml +198 -0
- package/ansible/monitoring/agent.yml +134 -0
- package/ansible/monitoring/beszel-agent.service +42 -0
- package/ansible/monitoring/compose.yml +74 -0
- package/ansible/monitoring/hub-darwin.yml +102 -0
- package/ansible/monitoring/lock.json +11 -0
- package/ansible/net/remote-probe.sh +13 -0
- package/ansible/ubuntu/authorize-sudo.sh +51 -0
- package/ansible/ubuntu/baseline.yml +89 -0
- package/ansible/ubuntu/disk-guard.py +104 -0
- package/ansible/ubuntu/policy.json +43 -0
- package/ansible/ubuntu/recovery.yml +15 -0
- package/ansible/ubuntu/t3/policy.json +7 -0
- package/ansible/ubuntu/t3-desktop.yml +220 -0
- package/ansible/ubuntu/t3-health.sh +33 -0
- package/ansible/ubuntu/t3-policy.yml +76 -0
- package/ansible/ubuntu/t3-route.yml +28 -0
- package/ansible/ubuntu/tailscale.yml +75 -0
- package/ansible/ubuntu/tasks/credentials.yml +47 -0
- package/ansible/ubuntu/tasks/docker.yml +31 -0
- package/ansible/ubuntu/tasks/packages.yml +56 -0
- package/ansible/ubuntu/tasks/power.yml +51 -0
- package/ansible/ubuntu/tasks/recovery.yml +39 -0
- package/ansible/ubuntu/tasks/resources.yml +252 -0
- package/ansible/ubuntu/tasks/services.yml +31 -0
- package/ansible/ubuntu/tasks/ssh.yml +71 -0
- package/ansible/ubuntu/tasks/user.yml +120 -0
- package/bin/devfleet.mjs +36 -0
- package/bootstrap/bootstrap.ps1 +50 -0
- package/bootstrap/bootstrap.sh +73 -0
- package/bootstrap/devfleet +7 -0
- package/bootstrap/docker-desktop.sh +33 -0
- package/bootstrap/doctor.ts +189 -0
- package/bootstrap/fleet-manager-run.sh +76 -0
- package/bootstrap/fleet-remote-bootstrap.sh +185 -0
- package/bootstrap/global-config.ts +113 -0
- package/bootstrap/install-launcher.sh +7 -0
- package/bootstrap/native-aws.ts +86 -0
- package/bootstrap/native-t3.ts +154 -0
- package/bootstrap/npm.ts +350 -0
- package/bootstrap/setup-status.ts +51 -0
- package/bootstrap/setup.ts +88 -0
- package/bootstrap/shell-path.ts +105 -0
- package/bootstrap/verify-t3-archive.py +49 -0
- package/bootstrap/worker-auth.ts +65 -0
- package/bootstrap/worker-github.ts +47 -0
- package/bootstrap/worker-tools.ts +24 -0
- package/cliproxy/AGENTS.md +11 -0
- package/cliproxy/README.md +269 -0
- package/cliproxy/clients/bootstrap.py +143 -0
- package/cliproxy/clients/launcher.py +57 -0
- package/cliproxy/compose.yaml +90 -0
- package/cliproxy/config.template.yaml +70 -0
- package/cliproxy/docs/cloud-adoption.md +16 -0
- package/cliproxy/docs/deployment.png +0 -0
- package/cliproxy/docs/deployment.svg +109 -0
- package/cliproxy/docs/health-workflow.example.yml +185 -0
- package/cliproxy/docs/migration.md +131 -0
- package/cliproxy/docs/model-health-report.schema.json +364 -0
- package/cliproxy/docs/model-health.md +393 -0
- package/cliproxy/image.lock.json +5 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
- package/cliproxy/models.json +1618 -0
- package/cliproxy/requirements.lock +78 -0
- package/cliproxy/scripts/accounts.py +38 -0
- package/cliproxy/scripts/audit.py +123 -0
- package/cliproxy/scripts/backup +234 -0
- package/cliproxy/scripts/check.py +34 -0
- package/cliproxy/scripts/checkpoint.py +226 -0
- package/cliproxy/scripts/cli.py +64 -0
- package/cliproxy/scripts/cliproxy_host.py +257 -0
- package/cliproxy/scripts/cloud_state.py +150 -0
- package/cliproxy/scripts/container-health +6 -0
- package/cliproxy/scripts/docker +24 -0
- package/cliproxy/scripts/external-health.py +79 -0
- package/cliproxy/scripts/fleet_config.py +43 -0
- package/cliproxy/scripts/fleet_sync.py +274 -0
- package/cliproxy/scripts/initialize +84 -0
- package/cliproxy/scripts/install-dependencies +48 -0
- package/cliproxy/scripts/install.py +406 -0
- package/cliproxy/scripts/model_health.py +16 -0
- package/cliproxy/scripts/modelhealth/__init__.py +5 -0
- package/cliproxy/scripts/modelhealth/checks.py +429 -0
- package/cliproxy/scripts/modelhealth/cli.py +117 -0
- package/cliproxy/scripts/modelhealth/config.py +186 -0
- package/cliproxy/scripts/modelhealth/emulator.py +536 -0
- package/cliproxy/scripts/modelhealth/expect.py +88 -0
- package/cliproxy/scripts/modelhealth/findings.py +183 -0
- package/cliproxy/scripts/modelhealth/protocol.py +330 -0
- package/cliproxy/scripts/modelhealth/redact.py +28 -0
- package/cliproxy/scripts/modelhealth/report.py +150 -0
- package/cliproxy/scripts/modelhealth/runner.py +390 -0
- package/cliproxy/scripts/modelhealth/scenario.py +131 -0
- package/cliproxy/scripts/modelhealth/schema.py +63 -0
- package/cliproxy/scripts/modelhealth/simulate.py +111 -0
- package/cliproxy/scripts/modelhealth/transport.py +213 -0
- package/cliproxy/scripts/monitor +68 -0
- package/cliproxy/scripts/ops +527 -0
- package/cliproxy/scripts/public-endpoint +127 -0
- package/cliproxy/scripts/recover_node.py +177 -0
- package/cliproxy/scripts/restore-verify +60 -0
- package/cliproxy/scripts/retention.py +144 -0
- package/cliproxy/scripts/service-start +7 -0
- package/cliproxy/scripts/validate-config +56 -0
- package/cliproxy/scripts/vault-access.py +57 -0
- package/cliproxy/scripts/vault-backup-login +55 -0
- package/cliproxy/scripts/vault-recovery +56 -0
- package/cliproxy/scripts/vault-renew +30 -0
- package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
- package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
- package/cliproxy/systemd/cliproxy-backup.service +13 -0
- package/cliproxy/systemd/cliproxy-backup.timer +8 -0
- package/cliproxy/systemd/cliproxy-fleet.service +17 -0
- package/cliproxy/systemd/cliproxy-gc.service +13 -0
- package/cliproxy/systemd/cliproxy-gc.timer +8 -0
- package/cliproxy/systemd/cliproxy-monitor.service +8 -0
- package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
- package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
- package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
- package/cliproxy/vendor/LICENSE +22 -0
- package/cliproxy/vendor/config.upstream.yaml +1349 -0
- package/examples/fleet.json +95 -0
- package/examples/roster.json +13 -0
- package/mise.lock +1492 -0
- package/mise.toml +99 -0
- package/package.json +65 -4
- package/schema/fleet.schema.json +364 -0
- package/schema/roster.schema.json +77 -0
- package/src/agents/account-relay.ts +111 -0
- package/src/agents/accounts.ts +85 -0
- package/src/agents/audit.ts +77 -0
- package/src/agents/cli.ts +13 -0
- package/src/agents/command.ts +204 -0
- package/src/agents/converge.ts +123 -0
- package/src/agents/doctor.ts +107 -0
- package/src/agents/errors.ts +2 -0
- package/src/agents/files.ts +73 -0
- package/src/agents/fleet-verify.ts +156 -0
- package/src/agents/launch.ts +290 -0
- package/src/agents/native-verify.ts +226 -0
- package/src/agents/provider-errors.ts +50 -0
- package/src/agents/registry.ts +55 -0
- package/src/agents/render.ts +110 -0
- package/src/agents/round-trip.ts +192 -0
- package/src/agents/secrets.ts +13 -0
- package/src/agents/toolchain.ts +34 -0
- package/src/agents/verify-cache.ts +103 -0
- package/src/agents/verify.ts +127 -0
- package/src/auth-checks.ts +163 -0
- package/src/cli/agent-guide.ts +95 -0
- package/src/cli/app.ts +115 -0
- package/src/cli/cli.ts +21 -0
- package/src/cli/command-index.ts +50 -0
- package/src/cli/contracts.ts +25 -0
- package/src/cli/fleet-reference.ts +17 -0
- package/src/cli/fleet-sync-checkout.ts +91 -0
- package/src/cli/fleet-sync-enrollment.ts +144 -0
- package/src/cli/fleet-sync-forward.ts +134 -0
- package/src/cli/fleet-sync-manager.ts +193 -0
- package/src/cli/fleet-sync-monitoring.ts +185 -0
- package/src/cli/fleet-sync-output.ts +314 -0
- package/src/cli/fleet-sync-privilege.ts +27 -0
- package/src/cli/fleet-sync-progress.ts +109 -0
- package/src/cli/fleet-sync-remote.ts +236 -0
- package/src/cli/fleet-sync-report.ts +26 -0
- package/src/cli/fleet-sync-schedule.ts +138 -0
- package/src/cli/fleet-sync-vault.ts +166 -0
- package/src/cli/fleet-sync.ts +309 -0
- package/src/cli/fleet.ts +163 -0
- package/src/cli/managed-health.ts +219 -0
- package/src/cli/node-deployment.ts +34 -0
- package/src/cli/node-sync.ts +114 -0
- package/src/cli/node-t3.ts +31 -0
- package/src/cli/options.ts +141 -0
- package/src/cli/probe-source.ts +7 -0
- package/src/cli/program.ts +169 -0
- package/src/cli/register-fleet.ts +246 -0
- package/src/cli/registry.ts +158 -0
- package/src/cli/repair-apply.ts +67 -0
- package/src/cli/repair-guard.ts +11 -0
- package/src/cli/repair-inspect.ts +150 -0
- package/src/cli/repair-script.ts +76 -0
- package/src/cli/repair.ts +209 -0
- package/src/cli/roster.ts +89 -0
- package/src/cli/search.ts +35 -0
- package/src/cli/settings.ts +64 -0
- package/src/cli/source-operations.ts +78 -0
- package/src/cli/t3/command.ts +90 -0
- package/src/cli/t3/register.ts +55 -0
- package/src/cli/tailnet.ts +127 -0
- package/src/cli/ui-actions.ts +133 -0
- package/src/cli/ui-controller.ts +113 -0
- package/src/cli/ui-data.ts +106 -0
- package/src/cli/ui-execute.ts +163 -0
- package/src/cli/ui-repairs.ts +34 -0
- package/src/cli/ui-reports.ts +211 -0
- package/src/cli/ui-targets.ts +100 -0
- package/src/cli/ui-terminal.ts +109 -0
- package/src/cli/ui-types.ts +49 -0
- package/src/cli/ui-wizard.ts +101 -0
- package/src/cli/ui.ts +9 -0
- package/src/cli/worker-health-output.ts +37 -0
- package/src/config.ts +186 -0
- package/src/configure.ts +255 -0
- package/src/definition.ts +260 -0
- package/src/diagnostics.ts +92 -0
- package/src/doctor-check.ts +10 -0
- package/src/errors.ts +1 -0
- package/src/fingerprint.ts +120 -0
- package/src/fleet-config.ts +88 -0
- package/src/index.ts +6 -0
- package/src/input-hashes.ts +73 -0
- package/src/machine/local-source.ts +52 -0
- package/src/machine/lock-error.ts +2 -0
- package/src/machine/platform.ts +44 -0
- package/src/machine/source.ts +68 -0
- package/src/machine/state.ts +185 -0
- package/src/machine-health.ts +95 -0
- package/src/managed-source.ts +47 -0
- package/src/manager-failure.ts +48 -0
- package/src/manager-sync.ts +400 -0
- package/src/manager.ts +62 -0
- package/src/monitoring/apply.ts +171 -0
- package/src/monitoring/beszel.ts +191 -0
- package/src/monitoring/enroll.ts +110 -0
- package/src/monitoring/hub-access.ts +113 -0
- package/src/monitoring/hub-viewer.ts +142 -0
- package/src/monitoring/hub.ts +93 -0
- package/src/monitoring/manager-agent.ts +73 -0
- package/src/monitoring/manager-hub.ts +232 -0
- package/src/monitoring/remote.ts +65 -0
- package/src/network-definition.ts +83 -0
- package/src/node-converge-linux.ts +372 -0
- package/src/node-converge-shared.ts +83 -0
- package/src/node-converge.ts +40 -0
- package/src/onboard.ts +35 -0
- package/src/platform/ansible-receipt.ts +128 -0
- package/src/platform/ansible.ts +62 -0
- package/src/platform/darwin.ts +203 -0
- package/src/platform/detect.ts +11 -0
- package/src/platform/fallback.ts +110 -0
- package/src/platform/index.ts +38 -0
- package/src/platform/launchd.ts +98 -0
- package/src/platform/linux.ts +139 -0
- package/src/platform/ps.ts +45 -0
- package/src/platform/resource-verify.ts +128 -0
- package/src/platform/systemd-user.ts +95 -0
- package/src/platform/t3-policy.ts +137 -0
- package/src/platform/t3-route.ts +68 -0
- package/src/platform/t3-service.ts +75 -0
- package/src/platform/types.ts +123 -0
- package/src/platform/ubuntu-audit.ts +63 -0
- package/src/platform/ubuntu-verify.ts +105 -0
- package/src/platform/windows.ts +148 -0
- package/src/power-recovery.ts +55 -0
- package/src/probe.ts +51 -0
- package/src/protocol.ts +119 -0
- package/src/proxy-converge.ts +85 -0
- package/src/remote-bootstrap.ts +212 -0
- package/src/remote-entry.ts +89 -0
- package/src/remote-lock.ts +12 -0
- package/src/repair-catalog.ts +206 -0
- package/src/repair-plan.ts +78 -0
- package/src/repair-runner.ts +84 -0
- package/src/report.ts +71 -0
- package/src/runner.ts +104 -0
- package/src/secrets.ts +169 -0
- package/src/service-auth.ts +115 -0
- package/src/ssh.ts +135 -0
- package/src/sync.ts +104 -0
- package/src/t3/accounts.ts +81 -0
- package/src/t3/desired.ts +165 -0
- package/src/t3/files.ts +81 -0
- package/src/t3/names-check.ts +53 -0
- package/src/t3/names.ts +155 -0
- package/src/t3/plan.ts +82 -0
- package/src/t3/providers.ts +74 -0
- package/src/t3/reconcile.ts +123 -0
- package/src/t3-endpoint.ts +48 -0
- package/src/t3-linked-check.ts +101 -0
- package/src/tailnet-health.ts +231 -0
- package/src/tailnet-matching.ts +37 -0
- package/src/tailscale.ts +141 -0
- package/src/test-fixtures.ts +36 -0
- package/src/toolchain-checks.ts +83 -0
- package/src/topology.ts +24 -0
- package/src/transport.ts +122 -0
- package/src/types.ts +95 -0
- package/src/vault-config.ts +38 -0
- package/src/worker-access-checks.ts +115 -0
- package/src/worker-capacity.ts +34 -0
- package/src/worker-host-checks.ts +176 -0
- package/src/worker-policy.ts +64 -0
- package/src/worker-readiness.ts +64 -0
- package/src/workstation-checks.ts +29 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Build protocol-specific request bodies from protocol-neutral turn fixtures."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def substitute(value, variables):
|
|
8
|
+
"""Replace {name} placeholders in every string of a fixture."""
|
|
9
|
+
if isinstance(value, str):
|
|
10
|
+
for name, text in variables.items():
|
|
11
|
+
value = value.replace("{" + name + "}", text)
|
|
12
|
+
return value
|
|
13
|
+
if isinstance(value, list):
|
|
14
|
+
return [substitute(item, variables) for item in value]
|
|
15
|
+
if isinstance(value, dict):
|
|
16
|
+
return {key: substitute(item, variables) for key, item in value.items()}
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _merge(base, extra):
|
|
21
|
+
for key, value in (extra or {}).items():
|
|
22
|
+
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
|
23
|
+
_merge(base[key], value)
|
|
24
|
+
else:
|
|
25
|
+
base[key] = copy.deepcopy(value)
|
|
26
|
+
return base
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _chat_tool(name, spec):
|
|
30
|
+
return {"type": "function", "function": {"name": name, "description": spec["description"], "parameters": spec["parameters"]}}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _responses_tool(name, spec):
|
|
34
|
+
return {"type": "function", "name": name, "description": spec["description"], "parameters": spec["parameters"]}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _chat_messages(turn):
|
|
38
|
+
messages = []
|
|
39
|
+
if turn.get("system"):
|
|
40
|
+
messages.append({"role": "system", "content": turn["system"]})
|
|
41
|
+
if turn.get("developer"):
|
|
42
|
+
messages.append({"role": "developer", "content": turn["developer"]})
|
|
43
|
+
for message in turn.get("messages", []):
|
|
44
|
+
if message["role"] == "assistant" and message.get("tool_calls"):
|
|
45
|
+
messages.append({
|
|
46
|
+
"role": "assistant",
|
|
47
|
+
"content": message.get("content"),
|
|
48
|
+
"tool_calls": [
|
|
49
|
+
{"id": c["id"], "type": "function", "function": {"name": c["name"], "arguments": c["arguments"]}}
|
|
50
|
+
for c in message["tool_calls"]
|
|
51
|
+
],
|
|
52
|
+
})
|
|
53
|
+
elif message["role"] == "tool":
|
|
54
|
+
messages.append({"role": "tool", "tool_call_id": message["tool_call_id"], "content": message["content"]})
|
|
55
|
+
else:
|
|
56
|
+
messages.append({"role": message["role"], "content": message["content"]})
|
|
57
|
+
return messages
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _responses_input(turn):
|
|
61
|
+
items = []
|
|
62
|
+
if turn.get("developer"):
|
|
63
|
+
items.append({"role": "developer", "content": turn["developer"]})
|
|
64
|
+
for message in turn.get("messages", []):
|
|
65
|
+
if message["role"] == "assistant" and message.get("tool_calls"):
|
|
66
|
+
if message.get("content"):
|
|
67
|
+
items.append({"role": "assistant", "content": message["content"]})
|
|
68
|
+
for call in message["tool_calls"]:
|
|
69
|
+
items.append({"type": "function_call", "call_id": call["id"], "name": call["name"], "arguments": call["arguments"]})
|
|
70
|
+
elif message["role"] == "tool":
|
|
71
|
+
items.append({"type": "function_call_output", "call_id": message["tool_call_id"], "output": message["content"]})
|
|
72
|
+
else:
|
|
73
|
+
items.append({"role": message["role"], "content": message["content"]})
|
|
74
|
+
return items
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _tool_choice(protocol, choice):
|
|
78
|
+
if choice is None or isinstance(choice, str):
|
|
79
|
+
return choice
|
|
80
|
+
name = choice["name"]
|
|
81
|
+
if protocol == "chat":
|
|
82
|
+
return {"type": "function", "function": {"name": name}}
|
|
83
|
+
return {"type": "function", "name": name}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build(protocol, model_id, turn, config, extra=None):
|
|
87
|
+
"""Return the JSON body for one request on the given wire protocol."""
|
|
88
|
+
tools = {name: config["tools"][name] for name in turn.get("tools", [])}
|
|
89
|
+
limit_field = config["protocols"][protocol]["token_limit_field"]
|
|
90
|
+
body = {"model": model_id}
|
|
91
|
+
if protocol == "chat":
|
|
92
|
+
body["messages"] = _chat_messages(turn)
|
|
93
|
+
if tools:
|
|
94
|
+
body["tools"] = [_chat_tool(n, s) for n, s in tools.items()]
|
|
95
|
+
if turn.get("json_schema"):
|
|
96
|
+
spec = turn["json_schema"]
|
|
97
|
+
body["response_format"] = {
|
|
98
|
+
"type": "json_schema",
|
|
99
|
+
"json_schema": {"name": spec["name"], "schema": spec["schema"], "strict": True},
|
|
100
|
+
}
|
|
101
|
+
if turn.get("stream") and turn.get("include_usage"):
|
|
102
|
+
body["stream_options"] = {"include_usage": True}
|
|
103
|
+
if tools and "parallel_tool_calls" in turn:
|
|
104
|
+
body["parallel_tool_calls"] = turn["parallel_tool_calls"]
|
|
105
|
+
else:
|
|
106
|
+
if turn.get("system"):
|
|
107
|
+
body["instructions"] = turn["system"]
|
|
108
|
+
body["input"] = _responses_input(turn)
|
|
109
|
+
body["store"] = False
|
|
110
|
+
if tools:
|
|
111
|
+
body["tools"] = [_responses_tool(n, s) for n, s in tools.items()]
|
|
112
|
+
if turn.get("json_schema"):
|
|
113
|
+
spec = turn["json_schema"]
|
|
114
|
+
body["text"] = {"format": {"type": "json_schema", "name": spec["name"], "schema": spec["schema"], "strict": True}}
|
|
115
|
+
if tools and "parallel_tool_calls" in turn:
|
|
116
|
+
body["parallel_tool_calls"] = turn["parallel_tool_calls"]
|
|
117
|
+
if tools and turn.get("tool_choice") is not None:
|
|
118
|
+
body["tool_choice"] = _tool_choice(protocol, turn["tool_choice"])
|
|
119
|
+
if turn.get("max_tokens"):
|
|
120
|
+
body[limit_field] = turn["max_tokens"]
|
|
121
|
+
if turn.get("stream"):
|
|
122
|
+
body["stream"] = True
|
|
123
|
+
return _merge(body, extra)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def tool_schemas(config, names):
|
|
127
|
+
return {name: config["tools"][name]["parameters"] for name in names}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def dumps(body):
|
|
131
|
+
return json.dumps(body, separators=(",", ":")).encode()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Minimal JSON Schema subset validator (no third-party dependency).
|
|
2
|
+
|
|
3
|
+
Supports the keywords used by tool and structured-output fixtures: type, enum,
|
|
4
|
+
const, properties, required, additionalProperties, items, minItems, maxItems,
|
|
5
|
+
minimum, maximum, minLength, maxLength and pattern.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
_TYPES = {
|
|
11
|
+
"object": lambda v: isinstance(v, dict),
|
|
12
|
+
"array": lambda v: isinstance(v, list),
|
|
13
|
+
"string": lambda v: isinstance(v, str),
|
|
14
|
+
"integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
|
|
15
|
+
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
|
|
16
|
+
"boolean": lambda v: isinstance(v, bool),
|
|
17
|
+
"null": lambda v: v is None,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def errors(value, schema, path="$"):
|
|
22
|
+
"""Return a list of 'path: problem' strings; empty means valid."""
|
|
23
|
+
found = []
|
|
24
|
+
expected = schema.get("type")
|
|
25
|
+
if expected is not None:
|
|
26
|
+
names = expected if isinstance(expected, list) else [expected]
|
|
27
|
+
if not any(_TYPES[name](value) for name in names):
|
|
28
|
+
return [path + ": expected " + "|".join(names)]
|
|
29
|
+
if "const" in schema and value != schema["const"]:
|
|
30
|
+
found.append(path + ": does not equal the required constant")
|
|
31
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
32
|
+
found.append(path + ": not one of the allowed values")
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
if len(value) < schema.get("minLength", 0):
|
|
35
|
+
found.append(path + ": shorter than minLength")
|
|
36
|
+
if len(value) > schema.get("maxLength", 1 << 30):
|
|
37
|
+
found.append(path + ": longer than maxLength")
|
|
38
|
+
if "pattern" in schema and not re.search(schema["pattern"], value):
|
|
39
|
+
found.append(path + ": does not match pattern")
|
|
40
|
+
if _TYPES["number"](value):
|
|
41
|
+
if "minimum" in schema and value < schema["minimum"]:
|
|
42
|
+
found.append(path + ": below minimum")
|
|
43
|
+
if "maximum" in schema and value > schema["maximum"]:
|
|
44
|
+
found.append(path + ": above maximum")
|
|
45
|
+
if isinstance(value, list):
|
|
46
|
+
if len(value) < schema.get("minItems", 0):
|
|
47
|
+
found.append(path + ": fewer than minItems")
|
|
48
|
+
if len(value) > schema.get("maxItems", 1 << 30):
|
|
49
|
+
found.append(path + ": more than maxItems")
|
|
50
|
+
if "items" in schema:
|
|
51
|
+
for index, item in enumerate(value):
|
|
52
|
+
found += errors(item, schema["items"], "%s[%d]" % (path, index))
|
|
53
|
+
if isinstance(value, dict):
|
|
54
|
+
properties = schema.get("properties", {})
|
|
55
|
+
for name in schema.get("required", []):
|
|
56
|
+
if name not in value:
|
|
57
|
+
found.append(path + "." + name + ": required")
|
|
58
|
+
for name, item in value.items():
|
|
59
|
+
if name in properties:
|
|
60
|
+
found += errors(item, properties[name], path + "." + name)
|
|
61
|
+
elif schema.get("additionalProperties") is False:
|
|
62
|
+
found.append(path + "." + name + ": unexpected property")
|
|
63
|
+
return found
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Run declarative routing, rate-limit and fault scenarios against the local emulator.
|
|
2
|
+
|
|
3
|
+
Evidence from these scenarios is labelled ``simulated``. It proves the harness
|
|
4
|
+
detects and classifies the behavior; it never proves the production proxy's
|
|
5
|
+
accounts, quotas or failover are healthy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from . import checks, protocol
|
|
12
|
+
from .emulator import Account, Emulator
|
|
13
|
+
from .redact import Redactor
|
|
14
|
+
from .transport import Client
|
|
15
|
+
|
|
16
|
+
DEFAULT_TURN = {"messages": [{"role": "user", "content": "Reply with the single word OK."}], "max_tokens": 64}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _expect(step_expect, outcomes, emulator, sent, elapsed):
|
|
20
|
+
"""Return a list of mismatches for one step's expectations."""
|
|
21
|
+
problems = []
|
|
22
|
+
last = outcomes[-1]
|
|
23
|
+
wanted = step_expect
|
|
24
|
+
if "status" in wanted and any(o.status != wanted["status"] for o in outcomes):
|
|
25
|
+
problems.append("HTTP status %s, expected %s" % ([o.status for o in outcomes], wanted["status"]))
|
|
26
|
+
if "category" in wanted and any(o.category != wanted["category"] for o in outcomes):
|
|
27
|
+
problems.append("category %s, expected %s" % ([o.category for o in outcomes], wanted["category"]))
|
|
28
|
+
served = [o.headers.get("x-emulator-account") for o in outcomes]
|
|
29
|
+
if "served_by_all" in wanted and any(s not in wanted["served_by_all"] for s in served):
|
|
30
|
+
problems.append("served by %s, expected only %s" % (served, wanted["served_by_all"]))
|
|
31
|
+
if "served_sequence" in wanted and served != wanted["served_sequence"]:
|
|
32
|
+
problems.append("served by %s, expected the sequence %s" % (served, wanted["served_sequence"]))
|
|
33
|
+
if "served_set" in wanted and set(served) != set(wanted["served_set"]):
|
|
34
|
+
problems.append("served by %s, expected exactly %s" % (sorted(set(served)), wanted["served_set"]))
|
|
35
|
+
if wanted.get("single_account") and len(set(served)) != 1:
|
|
36
|
+
problems.append("session was not pinned to one account: %s" % sorted(set(served)))
|
|
37
|
+
if wanted.get("retry_after") and not last.retry_after:
|
|
38
|
+
problems.append("Retry-After header was not surfaced")
|
|
39
|
+
if wanted.get("envelope") and "no error envelope" in (last.detail or ""):
|
|
40
|
+
problems.append("error response lacks an OpenAI error envelope")
|
|
41
|
+
if wanted.get("no_retry") and emulator.requests != sent:
|
|
42
|
+
problems.append("harness sent %d requests where %d were issued (hidden retry)" % (emulator.requests, sent))
|
|
43
|
+
if "max_elapsed_s" in wanted and elapsed > wanted["max_elapsed_s"]:
|
|
44
|
+
problems.append("took %.1fs, limit %.1fs" % (elapsed, wanted["max_elapsed_s"]))
|
|
45
|
+
if wanted.get("single_model") and len({o.norm.model for o in outcomes if o.norm}) != 1:
|
|
46
|
+
problems.append("response model id differed across accounts")
|
|
47
|
+
if "violation" in wanted:
|
|
48
|
+
if not any(wanted["violation"] in v for o in outcomes if o.norm for v in o.norm.violations):
|
|
49
|
+
problems.append("expected protocol violation %r was not detected" % wanted["violation"])
|
|
50
|
+
elif wanted.get("status") == 200:
|
|
51
|
+
for o in outcomes:
|
|
52
|
+
if o.norm is None or o.norm.violations:
|
|
53
|
+
problems.append("successful response was not contract-valid")
|
|
54
|
+
break
|
|
55
|
+
return problems
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_scenario(config, scenario, seed=0):
|
|
59
|
+
model_ids = [m["id"] for m in config.models]
|
|
60
|
+
accounts = []
|
|
61
|
+
for spec in scenario["accounts"]:
|
|
62
|
+
account = Account(spec["id"], quota=spec.get("quota"))
|
|
63
|
+
account.fail_next = spec.get("fail_next", 0)
|
|
64
|
+
accounts.append(account)
|
|
65
|
+
emulator = Emulator(model_ids, accounts=accounts, strategy=scenario.get("strategy", config.raw["routing"]["strategy"]))
|
|
66
|
+
base = emulator.start()
|
|
67
|
+
redact = Redactor([emulator.key])
|
|
68
|
+
timeouts = dict(config.raw["timeouts"], **scenario.get("timeouts", {}))
|
|
69
|
+
budget = checks.Budget(10_000, 10_000, 10_000_000)
|
|
70
|
+
ctx = checks.Context(config, config.models[0],
|
|
71
|
+
lambda model_id: Client(base, emulator.key, redact, timeouts["request_s"],
|
|
72
|
+
lambda kind: budget.take(model_id) if kind in ("inference", "probe") else None),
|
|
73
|
+
budget, random.Random(seed), redact, timeouts)
|
|
74
|
+
details, sent = [], 0
|
|
75
|
+
try:
|
|
76
|
+
for number, step in enumerate(scenario["steps"], 1):
|
|
77
|
+
action = step.get("action")
|
|
78
|
+
if action == "advance":
|
|
79
|
+
emulator.advance(step["seconds"])
|
|
80
|
+
elif action == "account":
|
|
81
|
+
account = emulator.account(step["id"])
|
|
82
|
+
account.quota = step.get("quota", account.quota)
|
|
83
|
+
account.fail_next = step.get("fail_next", account.fail_next)
|
|
84
|
+
elif action == "fault":
|
|
85
|
+
emulator.fault(**step["faults"])
|
|
86
|
+
elif action == "clear_faults":
|
|
87
|
+
emulator.clear_faults()
|
|
88
|
+
else:
|
|
89
|
+
request = step["request"]
|
|
90
|
+
turn = dict(request.get("turn") or DEFAULT_TURN)
|
|
91
|
+
outcomes, started = [], time.monotonic()
|
|
92
|
+
for index in range(step.get("count", 1)):
|
|
93
|
+
# Distinct sessions are new clients (cold bindings); "same" is one session (affinity).
|
|
94
|
+
session = "same" if request.get("session") == "same" else "s%d-%d" % (number, index)
|
|
95
|
+
outcomes.append(checks.call(ctx, request["protocol"], turn, {},
|
|
96
|
+
headers={"X-Session-Id": session if session != "same" else "pinned"}))
|
|
97
|
+
sent += 1
|
|
98
|
+
problems = _expect(step["expect"], outcomes, emulator, sent, time.monotonic() - started)
|
|
99
|
+
details += ["step %d: %s" % (number, p) for p in problems]
|
|
100
|
+
except Exception as error: # a scenario defect must be visible, not swallowed
|
|
101
|
+
details.append("harness error: %s" % type(error).__name__)
|
|
102
|
+
finally:
|
|
103
|
+
log = list(emulator.routing_log)
|
|
104
|
+
emulator.stop()
|
|
105
|
+
return {"id": scenario["id"], "capability": scenario["capability"], "title": scenario["title"],
|
|
106
|
+
"status": "fail" if details else "pass", "evidence": "simulated", "details": details,
|
|
107
|
+
"routing": [{"tried": entry["tried"], "served_by": entry["served_by"]} for entry in log]}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def run_all(config, seed=0):
|
|
111
|
+
return [run_scenario(config, scenario, seed) for scenario in config.raw.get("simulations", [])]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""HTTP/SSE client with strict framing checks, deadlines and no redirects or hidden retries."""
|
|
2
|
+
|
|
3
|
+
import http.client
|
|
4
|
+
import json
|
|
5
|
+
import ssl
|
|
6
|
+
import time
|
|
7
|
+
import urllib.parse
|
|
8
|
+
|
|
9
|
+
USER_AGENT = "cliproxy-fleet/1.0 model-health"
|
|
10
|
+
MAX_BODY = 4 * 1024 * 1024
|
|
11
|
+
_SSE_FIELDS = ("data", "event", "id", "retry")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TransportError(Exception):
|
|
15
|
+
"""A request that produced no HTTP response; category feeds failure reporting."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, category, detail):
|
|
18
|
+
super().__init__(detail)
|
|
19
|
+
self.category = category
|
|
20
|
+
self.detail = detail
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Response:
|
|
24
|
+
def __init__(self, status, headers, body, elapsed):
|
|
25
|
+
self.status = status
|
|
26
|
+
self.headers = {k.lower(): v for k, v in headers}
|
|
27
|
+
self.body = body
|
|
28
|
+
self.elapsed = elapsed
|
|
29
|
+
|
|
30
|
+
def json(self):
|
|
31
|
+
try:
|
|
32
|
+
return json.loads(self.body.decode("utf-8"))
|
|
33
|
+
except (ValueError, UnicodeDecodeError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Frame:
|
|
38
|
+
def __init__(self, event, data, at):
|
|
39
|
+
self.event = event
|
|
40
|
+
self.data = data
|
|
41
|
+
self.at = at # seconds since the request was sent
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class StreamResult:
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self.status = 0
|
|
47
|
+
self.headers = {}
|
|
48
|
+
self.frames = []
|
|
49
|
+
self.violations = []
|
|
50
|
+
self.error_body = b""
|
|
51
|
+
self.ttft = None # first frame carrying data
|
|
52
|
+
self.elapsed = 0.0
|
|
53
|
+
self.cancelled = False
|
|
54
|
+
self.done = False # saw the literal [DONE] sentinel
|
|
55
|
+
self.clean_eof = False # server ended the body after a complete frame
|
|
56
|
+
|
|
57
|
+
def json_frames(self):
|
|
58
|
+
out = []
|
|
59
|
+
for frame in self.frames:
|
|
60
|
+
if frame.data == "[DONE]":
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
out.append((frame.event, json.loads(frame.data)))
|
|
64
|
+
except ValueError:
|
|
65
|
+
self.violations.append("non-JSON data frame")
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _loopback(host):
|
|
70
|
+
return host in ("127.0.0.1", "localhost", "::1")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Client:
|
|
74
|
+
"""One origin + optional bearer key. Every call is a single attempt."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, base_url, key=None, redactor=None, timeout=60.0, on_request=None):
|
|
77
|
+
parts = urllib.parse.urlsplit(base_url)
|
|
78
|
+
if parts.scheme not in ("https", "http") or not parts.hostname:
|
|
79
|
+
raise ValueError("Endpoint must be an absolute URL")
|
|
80
|
+
if parts.scheme == "http" and not _loopback(parts.hostname):
|
|
81
|
+
raise ValueError("HTTPS required (plain HTTP is allowed only for loopback emulators)")
|
|
82
|
+
self.scheme = parts.scheme
|
|
83
|
+
self.host = parts.hostname
|
|
84
|
+
self.port = parts.port
|
|
85
|
+
self.prefix = parts.path.rstrip("/")
|
|
86
|
+
self.base_url = base_url.rstrip("/")
|
|
87
|
+
self.key = key
|
|
88
|
+
self.redact = redactor or (lambda text, limit=300: str(text)[:limit])
|
|
89
|
+
self.timeout = timeout
|
|
90
|
+
self.on_request = on_request or (lambda kind: None)
|
|
91
|
+
|
|
92
|
+
def _connect(self, timeout):
|
|
93
|
+
if self.scheme == "https":
|
|
94
|
+
return http.client.HTTPSConnection(
|
|
95
|
+
self.host, self.port, timeout=timeout, context=ssl.create_default_context()
|
|
96
|
+
)
|
|
97
|
+
return http.client.HTTPConnection(self.host, self.port, timeout=timeout)
|
|
98
|
+
|
|
99
|
+
def _headers(self, authenticated, body, extra):
|
|
100
|
+
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
101
|
+
if authenticated and self.key:
|
|
102
|
+
headers["Authorization"] = "Bearer " + self.key
|
|
103
|
+
if body is not None:
|
|
104
|
+
headers["Content-Type"] = "application/json"
|
|
105
|
+
headers.update(extra or {})
|
|
106
|
+
return headers
|
|
107
|
+
|
|
108
|
+
def _send(self, method, path, body, authenticated, timeout, extra, kind):
|
|
109
|
+
self.on_request(kind)
|
|
110
|
+
connection = self._connect(timeout or self.timeout)
|
|
111
|
+
payload = None
|
|
112
|
+
if body is not None:
|
|
113
|
+
payload = body if isinstance(body, bytes) else json.dumps(body).encode()
|
|
114
|
+
started = time.monotonic()
|
|
115
|
+
try:
|
|
116
|
+
connection.request(
|
|
117
|
+
method, self.prefix + path, payload, self._headers(authenticated, payload, extra)
|
|
118
|
+
)
|
|
119
|
+
return connection, connection.getresponse(), started
|
|
120
|
+
except Exception as error:
|
|
121
|
+
connection.close()
|
|
122
|
+
raise self._classify(error) from None
|
|
123
|
+
|
|
124
|
+
def _classify(self, error):
|
|
125
|
+
text = self.redact(type(error).__name__ + ": " + str(error))
|
|
126
|
+
if isinstance(error, (TimeoutError,)) or "timed out" in str(error):
|
|
127
|
+
return TransportError("timeout", text)
|
|
128
|
+
return TransportError("transport", text)
|
|
129
|
+
|
|
130
|
+
def request(self, method, path, body=None, authenticated=True, timeout=None,
|
|
131
|
+
headers=None, kind="request"):
|
|
132
|
+
connection, response, started = self._send(
|
|
133
|
+
method, path, body, authenticated, timeout, headers, kind
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
data = response.read(MAX_BODY + 1)
|
|
137
|
+
except Exception as error:
|
|
138
|
+
raise self._classify(error) from None
|
|
139
|
+
finally:
|
|
140
|
+
connection.close()
|
|
141
|
+
if len(data) > MAX_BODY:
|
|
142
|
+
raise TransportError("protocol", "Response body exceeds the harness limit")
|
|
143
|
+
return Response(response.status, response.getheaders(), data, time.monotonic() - started)
|
|
144
|
+
|
|
145
|
+
def stream(self, path, body, timeout=None, deadline=None, stop_after=None,
|
|
146
|
+
headers=None, kind="inference"):
|
|
147
|
+
"""POST a streaming request and parse SSE frames.
|
|
148
|
+
|
|
149
|
+
timeout is the per-read idle limit; deadline bounds the whole stream.
|
|
150
|
+
stop_after closes the connection after N data frames (cancellation).
|
|
151
|
+
"""
|
|
152
|
+
result = StreamResult()
|
|
153
|
+
extra = {"Accept": "text/event-stream"}
|
|
154
|
+
extra.update(headers or {})
|
|
155
|
+
connection, response, started = self._send(
|
|
156
|
+
"POST", path, body, True, timeout, extra, kind
|
|
157
|
+
)
|
|
158
|
+
result.status = response.status
|
|
159
|
+
result.headers = {k.lower(): v for k, v in response.getheaders()}
|
|
160
|
+
try:
|
|
161
|
+
if response.status != 200:
|
|
162
|
+
result.error_body = response.read(64 * 1024)
|
|
163
|
+
return result
|
|
164
|
+
if "text/event-stream" not in result.headers.get("content-type", ""):
|
|
165
|
+
result.violations.append("content-type is not text/event-stream")
|
|
166
|
+
event, data_lines, saw_field = None, [], False
|
|
167
|
+
limit = deadline or (self.timeout * 4)
|
|
168
|
+
while True:
|
|
169
|
+
if time.monotonic() - started > limit:
|
|
170
|
+
raise TransportError("timeout", "Stream exceeded its overall deadline")
|
|
171
|
+
try:
|
|
172
|
+
raw = response.readline()
|
|
173
|
+
except Exception as error:
|
|
174
|
+
raise self._classify(error) from None
|
|
175
|
+
if not raw:
|
|
176
|
+
result.clean_eof = not saw_field
|
|
177
|
+
if saw_field:
|
|
178
|
+
result.violations.append("stream ended inside an unterminated frame")
|
|
179
|
+
break
|
|
180
|
+
line = raw.decode("utf-8", "replace").rstrip("\r\n")
|
|
181
|
+
if line == "":
|
|
182
|
+
if saw_field:
|
|
183
|
+
at = time.monotonic() - started
|
|
184
|
+
frame = Frame(event, "\n".join(data_lines), at)
|
|
185
|
+
if data_lines:
|
|
186
|
+
result.frames.append(frame)
|
|
187
|
+
if result.ttft is None:
|
|
188
|
+
result.ttft = at
|
|
189
|
+
if frame.data == "[DONE]":
|
|
190
|
+
result.done = True
|
|
191
|
+
event, data_lines, saw_field = None, [], False
|
|
192
|
+
if stop_after and len(result.frames) >= stop_after:
|
|
193
|
+
result.cancelled = True
|
|
194
|
+
break
|
|
195
|
+
continue
|
|
196
|
+
if line.startswith(":"):
|
|
197
|
+
continue # comment / keep-alive
|
|
198
|
+
name, sep, value = line.partition(":")
|
|
199
|
+
if name not in _SSE_FIELDS or (not sep and name != "data"):
|
|
200
|
+
result.violations.append("invalid SSE line")
|
|
201
|
+
continue
|
|
202
|
+
value = value[1:] if value.startswith(" ") else value
|
|
203
|
+
saw_field = True
|
|
204
|
+
if name == "data":
|
|
205
|
+
data_lines.append(value)
|
|
206
|
+
elif name == "event":
|
|
207
|
+
event = value
|
|
208
|
+
if result.done and name == "data":
|
|
209
|
+
result.violations.append("data after [DONE]")
|
|
210
|
+
finally:
|
|
211
|
+
connection.close()
|
|
212
|
+
result.elapsed = time.monotonic() - started
|
|
213
|
+
return result
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
sys.dont_write_bytecode = True
|
|
5
|
+
|
|
6
|
+
import pathlib as _pathlib
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(_pathlib.Path(__file__).resolve().parent))
|
|
9
|
+
import cliproxy_host
|
|
10
|
+
|
|
11
|
+
import datetime, json, pathlib, subprocess, sys, os
|
|
12
|
+
|
|
13
|
+
r = cliproxy_host.default_root().resolve()
|
|
14
|
+
issues = []
|
|
15
|
+
provenance = subprocess.run([str(r / "scripts/audit.py")], capture_output=True)
|
|
16
|
+
if provenance.returncode:
|
|
17
|
+
issues.append("Installed code differs from its committed-source manifest.")
|
|
18
|
+
p = subprocess.run([str(r / "scripts/ops"), "health"], capture_output=True)
|
|
19
|
+
if p.returncode:
|
|
20
|
+
issues.append("Application health/authentication failed; inspect sanitized logs.")
|
|
21
|
+
try:
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
strategy = yaml.safe_load((r / "state/runtime/config.yaml").read_text())["routing"]["strategy"]
|
|
25
|
+
if strategy != "fill-first":
|
|
26
|
+
issues.append(
|
|
27
|
+
"Live routing strategy is not fill-first (drift from config.template.yaml); apply a reviewed config with `cliproxy apply`."
|
|
28
|
+
)
|
|
29
|
+
except Exception:
|
|
30
|
+
issues.append("Live routing strategy could not be read.")
|
|
31
|
+
f = r / "state/status/backup-success.json"
|
|
32
|
+
if not f.exists():
|
|
33
|
+
issues.append("No successful independent backup has been recorded.")
|
|
34
|
+
else:
|
|
35
|
+
try:
|
|
36
|
+
t = datetime.datetime.fromisoformat(json.loads(f.read_text())["time"])
|
|
37
|
+
if (datetime.datetime.now(datetime.timezone.utc) - t).total_seconds() > 45 * 60:
|
|
38
|
+
issues.append("Cloud checkpoint older than 45 minutes.")
|
|
39
|
+
except Exception:
|
|
40
|
+
issues.append("Invalid backup status record.")
|
|
41
|
+
if (r / "state/status/vault-failed").exists():
|
|
42
|
+
issues.append("Vault login/renewal failed; backups cannot fetch credentials.")
|
|
43
|
+
for marker in [
|
|
44
|
+
"backup-failed",
|
|
45
|
+
"backup-check-failed",
|
|
46
|
+
"backup-operation-failed",
|
|
47
|
+
"gc-failed",
|
|
48
|
+
]:
|
|
49
|
+
if (r / "state/status" / marker).exists():
|
|
50
|
+
issues.append(marker + ": inspect failed operation.")
|
|
51
|
+
gc = r / "state/status/gc-success.json"
|
|
52
|
+
try:
|
|
53
|
+
age = datetime.datetime.now(
|
|
54
|
+
datetime.timezone.utc
|
|
55
|
+
) - datetime.datetime.fromisoformat(json.loads(gc.read_text())["time"])
|
|
56
|
+
if age.total_seconds() > 9 * 86400:
|
|
57
|
+
issues.append("Garbage collection older than nine days.")
|
|
58
|
+
except Exception:
|
|
59
|
+
issues.append("No valid successful garbage collection record.")
|
|
60
|
+
f = r / "state/status/monitor-failed"
|
|
61
|
+
if issues:
|
|
62
|
+
f.write_text("\n".join(issues) + "\n")
|
|
63
|
+
if os.geteuid() == 0:
|
|
64
|
+
os.chown(f, 1000, 1000)
|
|
65
|
+
print(f.read_text())
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
f.unlink(missing_ok=True)
|
|
68
|
+
print("Health and backup freshness passed.")
|