agent build
This commit is contained in:
303
scripts/verify-deploy.py
Executable file
303
scripts/verify-deploy.py
Executable file
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Verify a deployed Krow API, endpoint by endpoint.
|
||||
|
||||
KROW_EMAIL=you@example.com KROW_PASSWORD=... \
|
||||
python3 scripts/verify-deploy.py https://mcp.krowforce.com
|
||||
|
||||
make verify-deploy BASE=https://mcp.krowforce.com
|
||||
|
||||
Credentials come from the environment, never from an argument, so they do not
|
||||
land in shell history or in a process list.
|
||||
|
||||
READ-ONLY by default. The two write paths (hire, assignment) are exercised only
|
||||
with --write, because they change tenant data and a smoke test that mutates the
|
||||
thing it is checking is not a smoke test.
|
||||
|
||||
Why this exists: this API runs its auth middleware BEFORE routing, so an
|
||||
unauthenticated probe answers 401 for every path — including paths that do not
|
||||
exist. `curl` against a deployed host therefore cannot tell a missing endpoint
|
||||
from a guarded one, and the only honest check is an authenticated one.
|
||||
|
||||
Exit code is non-zero if any check fails.
|
||||
"""
|
||||
import json, os, sys, time, urllib.request, urllib.parse, urllib.error, http.cookiejar
|
||||
|
||||
BASE = (sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-")
|
||||
else os.environ.get("KROW_BASE_URL", "http://127.0.0.1:8080")).rstrip("/")
|
||||
WRITE = "--write" in sys.argv
|
||||
|
||||
class BrowserLikePolicy(http.cookiejar.DefaultCookiePolicy):
|
||||
"""Accept Secure cookies over http://localhost, as every browser does.
|
||||
|
||||
Browsers treat localhost as a potentially-trustworthy origin, so a Secure
|
||||
cookie set through a dev-server proxy is stored and sent. Python's default
|
||||
policy refuses it, which makes a perfectly working frontend look like a
|
||||
broken session: login returns 200 and the very next request is 401.
|
||||
|
||||
Only localhost. Anywhere else, a Secure cookie over plaintext is refused as
|
||||
it should be.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _trustworthy(request):
|
||||
host = urllib.parse.urlparse(request.get_full_url()).hostname or ""
|
||||
return host in ("localhost", "127.0.0.1", "::1", "[::1]")
|
||||
|
||||
def set_ok_secure(self, cookie, request):
|
||||
return self._trustworthy(request) or super().set_ok_secure(cookie, request)
|
||||
|
||||
def return_ok_secure(self, cookie, request):
|
||||
return self._trustworthy(request) or super().return_ok_secure(cookie, request)
|
||||
|
||||
|
||||
jar = http.cookiejar.CookieJar(policy=BrowserLikePolicy())
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
results = []
|
||||
|
||||
def call(method, path, body=None, accept="application/json", timeout=45):
|
||||
"""Returns (status, text, headers). Never raises for an HTTP status."""
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(BASE + path, data=data, method=method)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Accept", accept)
|
||||
try:
|
||||
with opener.open(req, timeout=timeout) as r:
|
||||
return r.status, r.read().decode("utf-8", "replace"), dict(r.headers)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", "replace"), dict(e.headers)
|
||||
except Exception as e:
|
||||
return 0, f"{type(e).__name__}: {e}", {}
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
results.append((name, bool(ok), detail))
|
||||
print(f"[{' ok ' if ok else ' FAIL '}] {name}" + (f" — {detail}" if detail else ""))
|
||||
return ok
|
||||
|
||||
def group(title):
|
||||
print(f"\n── {title} " + "─" * max(0, 66 - len(title)))
|
||||
|
||||
def as_json(text):
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# resource -> the operations it declares (internal/domain/resources_gen.go).
|
||||
# Anything not declared is deliberately unregistered: "the database having a
|
||||
# table is never a reason for an endpoint to exist" (api.go). `badges` declares
|
||||
# nothing at all, so every badges path is correctly a 404.
|
||||
RESOURCE_OPS = {
|
||||
"job-postings": ["List", "Get", "Create", "Update"],
|
||||
"job-applications": ["List", "Create", "Update", "Delete"],
|
||||
"ai-interviews": ["List", "Create"],
|
||||
"staff": ["List", "Create", "Update"],
|
||||
"worker-profiles": ["List", "Create", "Update"],
|
||||
"courses": ["List", "Get", "Create", "Update"],
|
||||
"learning-paths": ["List"],
|
||||
"role-categories": ["List", "Create"],
|
||||
"certifications": ["List", "Create", "Delete"],
|
||||
"user-activity": ["List", "Create"],
|
||||
"evidence": ["List", "Create", "Update"],
|
||||
"assignments": ["List", "Create"],
|
||||
"shift-records": ["List"],
|
||||
"badges": [],
|
||||
}
|
||||
RESOURCES = [r for r, ops in RESOURCE_OPS.items() if "List" in ops]
|
||||
|
||||
print(f"Verifying {BASE} ({'read/write' if WRITE else 'read-only'})")
|
||||
|
||||
# ── 1. Reachable, and guarded ────────────────────────────────────────────────
|
||||
group("Reachable and guarded")
|
||||
s, t, _ = call("GET", "/health")
|
||||
health = as_json(t) or {}
|
||||
check("/health answers 200", s == 200, f"{s} {health.get('status', t[:40])}")
|
||||
check("/health reports a healthy database", health.get("status") == "ok",
|
||||
f"status={health.get('status')} (degraded = schema unmigrated or dirty)")
|
||||
s, _, _ = call("GET", "/api/v1/job-postings")
|
||||
check("a protected endpoint refuses an anonymous caller", s in (401, 403), f"{s}")
|
||||
|
||||
# ── 2. Sign in ───────────────────────────────────────────────────────────────
|
||||
group("Authentication")
|
||||
email, password = os.environ.get("KROW_EMAIL"), os.environ.get("KROW_PASSWORD")
|
||||
if not (email and password):
|
||||
print("\nKROW_EMAIL / KROW_PASSWORD are not set — cannot check anything behind auth.")
|
||||
print("Everything below needs a session. Set them and re-run.")
|
||||
sys.exit(2)
|
||||
|
||||
s, t, _ = call("POST", "/api/v1/auth/login", {"email": email, "password": password})
|
||||
if not check("sign-in succeeds", s == 200, str(s)):
|
||||
print("\nNo session — stopping. Every remaining check needs one.")
|
||||
sys.exit(1)
|
||||
check("a session cookie was set", len(jar) > 0, f"{len(jar)} cookie(s)")
|
||||
|
||||
s, t, _ = call("GET", "/api/v1/me")
|
||||
me = (as_json(t) or {}).get("data") or {}
|
||||
check("GET /api/v1/me returns the signed-in user", s == 200 and bool(me), f"{s}")
|
||||
check("...and it is the account that signed in",
|
||||
str(me.get("email", "")).lower() == email.lower(), me.get("email", "?"))
|
||||
role = me.get("role", "?")
|
||||
print(f" signed in as {me.get('email','?')} (role: {role})")
|
||||
|
||||
s, _, _ = call("GET", "/api/v1/me/preferences")
|
||||
check("GET /api/v1/me/preferences", s == 200, f"{s}")
|
||||
|
||||
# Which build is actually serving. Without this, "did my deploy land?" has no
|
||||
# answer and a redeploy that silently rolled back looks identical to one that
|
||||
# worked. Set KROW_EXPECT_VERSION to make a stale deployment a failure.
|
||||
s, t, _ = call("GET", "/api/v1/version")
|
||||
build = (as_json(t) or {}).get("data") or {}
|
||||
running = build.get("version", "")
|
||||
check("GET /api/v1/version reports the running build", s == 200 and bool(running),
|
||||
f"{s}, version={running or 'none'}, env={build.get('env')}, "
|
||||
f"endpoints={build.get('endpoints')}")
|
||||
if running == "unknown":
|
||||
check("...and the build was actually stamped", False,
|
||||
"reports \"unknown\" — built without -X main.version, so it cannot be traced")
|
||||
expected = os.environ.get("KROW_EXPECT_VERSION")
|
||||
if expected:
|
||||
check("...and it is the build you expected", running == expected,
|
||||
f"running {running!r}, expected {expected!r}")
|
||||
|
||||
# ── 3. Every resource collection ─────────────────────────────────────────────
|
||||
group(f"Resource endpoints ({len(RESOURCES)} collections)")
|
||||
first_ids = {}
|
||||
for r in RESOURCES:
|
||||
s, t, _ = call("GET", f"/api/v1/{r}")
|
||||
body = as_json(t) or {}
|
||||
recs = body.get("data")
|
||||
ok = s == 200 and isinstance(recs, list) and isinstance(body.get("meta"), dict)
|
||||
total = (body.get("meta") or {}).get("total")
|
||||
check(f"GET /api/v1/{r}", ok, f"{s}" + (f", {len(recs)} records, meta.total={total}" if ok else f" {t[:90]}"))
|
||||
if ok and recs:
|
||||
first_ids[r] = recs[0].get("id")
|
||||
|
||||
group("Reading one record by id (only where the resource declares Get)")
|
||||
for r, rid in first_ids.items():
|
||||
s, t, _ = call("GET", f"/api/v1/{r}/{rid}")
|
||||
if "Get" in RESOURCE_OPS[r]:
|
||||
check(f"GET /api/v1/{r}/{{id}}", s == 200, f"{s}")
|
||||
else:
|
||||
check(f"GET /api/v1/{r}/{{id}} is refused — it declares no Get",
|
||||
s in (404, 405), f"{s}")
|
||||
|
||||
group("A resource that declares nothing exposes nothing")
|
||||
for r, ops in RESOURCE_OPS.items():
|
||||
if ops:
|
||||
continue
|
||||
s, _, _ = call("GET", f"/api/v1/{r}")
|
||||
check(f"GET /api/v1/{r} → 404", s == 404, f"{s}")
|
||||
|
||||
group("An id that does not exist is 404, not 500")
|
||||
s, _, _ = call("GET", "/api/v1/job-postings/00000000-0000-0000-0000-000000000000")
|
||||
check("unknown id → 404", s == 404, f"{s}")
|
||||
s, _, _ = call("GET", "/api/v1/job-postings/not-a-uuid")
|
||||
check("malformed id → 4xx, never 5xx", 400 <= s < 500, f"{s}")
|
||||
|
||||
# ── 4. The agent layer ───────────────────────────────────────────────────────
|
||||
group("Agent and skill registry")
|
||||
s, t, _ = call("GET", "/api/v1/agent-definitions")
|
||||
body = as_json(t) or {}
|
||||
agents = body.get("data") if isinstance(body, dict) else body
|
||||
agents = agents if isinstance(agents, list) else []
|
||||
check("GET /api/v1/agent-definitions", s == 200 and isinstance(agents, list), f"{s}, {len(agents)} agents")
|
||||
if agents:
|
||||
print(" " + ", ".join(sorted(str(a.get("definition_id") or a.get("id")) for a in agents)))
|
||||
# Two different keys, deliberately. The registry endpoint is a CRUD resource
|
||||
# keyed by uuid (repo.GetAgent: WHERE id = $1::uuid); the run endpoint is
|
||||
# addressed by the stable definition_id a spec author writes. Passing the
|
||||
# definition_id to the registry endpoint is a 404, which is correct.
|
||||
row_uuid = agents[0].get("id")
|
||||
s, _, _ = call("GET", f"/api/v1/agent-definitions/{row_uuid}")
|
||||
check("GET /api/v1/agent-definitions/{uuid}", s == 200, f"{s}")
|
||||
s, _, _ = call("GET", f"/api/v1/agent-definitions/{agents[0].get('definition_id')}")
|
||||
check("...and the definition_id is not a uuid, so it is refused there", s == 404, f"{s}")
|
||||
|
||||
s, t, _ = call("GET", "/api/v1/skill-definitions")
|
||||
body = as_json(t) or {}
|
||||
skills = body.get("data") if isinstance(body, dict) else body
|
||||
skills = skills if isinstance(skills, list) else []
|
||||
check("GET /api/v1/skill-definitions", s == 200, f"{s}, {len(skills)} skills")
|
||||
|
||||
s, t, _ = call("GET", "/api/v1/owliver/suggestions?page=control-center")
|
||||
check("GET /api/v1/owliver/suggestions?page=...", s == 200, f"{s}")
|
||||
s, _, _ = call("GET", "/api/v1/owliver/suggestions")
|
||||
check("...and it requires a page rather than guessing one", s == 400, f"{s}")
|
||||
s, _, _ = call("GET", "/api/v1/owliver/suggestions?page=not-a-real-page")
|
||||
check("...and rejects a page that does not exist", s == 400, f"{s}")
|
||||
|
||||
# ── 5. An actual agent run ───────────────────────────────────────────────────
|
||||
group("Running an agent (this calls the model — it costs tokens)")
|
||||
run_id = None
|
||||
if not agents:
|
||||
check("an agent run completes", False, "no agents are published on this deployment")
|
||||
else:
|
||||
aid = agents[0].get("definition_id") or agents[0].get("id")
|
||||
t0 = time.time()
|
||||
s, t, _ = call("POST", f"/api/v1/agents/{aid}/runs",
|
||||
{"input": "What can you help me with? Answer in one sentence."})
|
||||
body = as_json(t) or {}
|
||||
took = time.time() - t0
|
||||
ok = s == 200 and body.get("termination") is not None
|
||||
check(f"POST /api/v1/agents/{aid}/runs", ok,
|
||||
f"{s}, termination={body.get('termination')}, {took:.1f}s" if ok else f"{s} {t[:160]}")
|
||||
if ok:
|
||||
run_id = body.get("runId") or body.get("run_id")
|
||||
check("...the run terminated cleanly",
|
||||
body.get("termination") in ("Completed", "ConfirmationPending"),
|
||||
str(body.get("termination")))
|
||||
check("...and it produced an answer",
|
||||
bool(body.get("output") or body.get("message") or body.get("confirmations")),
|
||||
(body.get("output") or body.get("message") or "")[:70] or "confirmation proposed")
|
||||
usage = body.get("usage") or {}
|
||||
check("...with token accounting attached",
|
||||
(usage.get("inputTokens", 0) or 0) > 0,
|
||||
f"in={usage.get('inputTokens')} out={usage.get('outputTokens')}")
|
||||
|
||||
if run_id:
|
||||
s, t, _ = call("GET", f"/api/v1/runs/{run_id}")
|
||||
rb = as_json(t) or {}
|
||||
check("GET /api/v1/runs/{id} returns the trajectory", s == 200, f"{s}")
|
||||
entries = rb.get("entries")
|
||||
check("...with the trajectory persisted",
|
||||
isinstance(entries, list) and len(entries) > 0,
|
||||
f"{len(entries) if isinstance(entries, list) else 0} entries, "
|
||||
f"termination={rb.get('termination')}, model={rb.get('model') or '?'}")
|
||||
check("...and the run pins the agent version it started with",
|
||||
isinstance(rb.get("agentVersion"), int) and rb["agentVersion"] > 0,
|
||||
f"v{rb.get('agentVersion')}")
|
||||
|
||||
# Streaming is the path the chat panel actually uses.
|
||||
s, t, h = call("POST", f"/api/v1/agents/{aid}/runs",
|
||||
{"input": "Say hello in five words."}, accept="text/event-stream")
|
||||
ctype = (h.get("Content-Type") or h.get("content-type") or "")
|
||||
check("the same endpoint streams on Accept: text/event-stream",
|
||||
s == 200 and "event-stream" in ctype, f"{s}, content-type={ctype or 'none'}")
|
||||
check("...and the stream carries more than one event",
|
||||
t.count("data:") > 1, f"{t.count('data:')} data frames")
|
||||
|
||||
# ── 6. Writes (only with --write) ────────────────────────────────────────────
|
||||
group("Write paths")
|
||||
if not WRITE:
|
||||
print(" skipped — re-run with --write to exercise hire and assignment")
|
||||
else:
|
||||
s, t, _ = call("POST", "/api/v1/job-postings/x/assignments", {})
|
||||
check("POST assignments rejects a bad request rather than 500", 400 <= s < 500, f"{s}")
|
||||
s, t, _ = call("POST", "/api/v1/job-applications/x/hire", {})
|
||||
check("POST hire rejects a bad request rather than 500", 400 <= s < 500, f"{s}")
|
||||
|
||||
# ── 7. Sign out ──────────────────────────────────────────────────────────────
|
||||
group("Sign out")
|
||||
s, _, _ = call("POST", "/api/v1/auth/logout")
|
||||
check("POST /api/v1/auth/logout", s in (200, 204), f"{s}")
|
||||
s, _, _ = call("GET", "/api/v1/me")
|
||||
check("the session is dead afterwards", s in (401, 403), f"{s}")
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────────────
|
||||
failed = [r for r in results if not r[1]]
|
||||
print(f"\n{len(results) - len(failed)}/{len(results)} checks passed")
|
||||
if failed:
|
||||
print("\nFailed:")
|
||||
for name, _, detail in failed:
|
||||
print(f" - {name}{f' ({detail})' if detail else ''}")
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user