Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
172 lines
9.1 KiB
Python
172 lines
9.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise model migration conflicts in PostgreSQL, always rolling back.
|
|
|
|
An optional single Pro model debug request records only a status, finish reason,
|
|
token counts, and elapsed time. SQL, credentials, and response text never leave
|
|
memory or appear in the report.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime
|
|
import json
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from apply_weknora_identities import sql_run
|
|
from apply_weknora_models import native_encryptor, resolve_source_credential, target_snapshot
|
|
from verify_identity_etl import rejected_case
|
|
from weknora_category_etl import literal, save_json
|
|
from weknora_etl import consistent_snapshot
|
|
from weknora_model_etl import (LEDGER, TARGET_DATABASE, TARGET_TENANT, model_id,
|
|
model_plan, model_sql, read_credentials)
|
|
|
|
|
|
def ledger_snapshot():
|
|
return json.loads(sql_run(f"SELECT coalesce(json_agg(to_jsonb(l) ORDER BY source_catalog_id),'[]') "
|
|
f"FROM {LEDGER} l;"))
|
|
|
|
|
|
def regression_sql(plan, query):
|
|
uid = literal(plan["models"][0]["target_id"])
|
|
cases = [
|
|
("mapped_model_wrong_tenant_rejected", f"UPDATE models SET tenant_id={TARGET_TENANT + 1} WHERE id={uid};",
|
|
"Previously migrated model is missing or incompatible"),
|
|
("ledger_wrong_tenant_rejected", f"UPDATE {LEDGER} SET target_tenant_id={TARGET_TENANT + 1} WHERE target_model_id={uid};",
|
|
"Model migration ledger mapping differs"),
|
|
("ledger_wrong_model_id_rejected", f"UPDATE {LEDGER} SET target_model_id='rollback-only-conflicting-id' WHERE target_model_id={uid};",
|
|
"Model migration ledger mapping differs"),
|
|
("ledger_wrong_provider_rejected", f"UPDATE {LEDGER} SET source_provider_id=999 WHERE target_model_id={uid};",
|
|
"Model migration ledger mapping differs"),
|
|
("ledger_changed_source_rejected", f"UPDATE {LEDGER} SET source_snapshot='{{}}'::jsonb WHERE target_model_id={uid};",
|
|
"Model migration ledger mapping differs"),
|
|
("existing_model_id_without_ledger_rejected", f"DELETE FROM {LEDGER} WHERE target_model_id={uid};",
|
|
"Model ID collision without migration provenance"),
|
|
("deleted_model_not_revived", f"UPDATE models SET deleted_at=now() WHERE id={uid};",
|
|
"Previously migrated model is missing or incompatible"),
|
|
("wrong_model_type_rejected", f"UPDATE models SET type='Embedding' WHERE id={uid};",
|
|
"Previously migrated model is missing or incompatible"),
|
|
("inactive_target_tenant_rejected", f"UPDATE tenants SET status='suspended' WHERE id={TARGET_TENANT};",
|
|
"Model migration requires the active acceptance tenant"),
|
|
]
|
|
sql = f"""BEGIN ISOLATION LEVEL SERIALIZABLE;
|
|
SET LOCAL statement_timeout='30s';
|
|
SET LOCAL lock_timeout='5s';
|
|
SET LOCAL log_statement='none';
|
|
SET LOCAL log_min_error_statement='panic';
|
|
CREATE TEMP TABLE _models_before ON COMMIT DROP AS SELECT * FROM models;
|
|
CREATE TEMP TABLE _model_ledger_before ON COMMIT DROP AS SELECT * FROM {LEDGER};
|
|
{query}
|
|
DO $model_repeat$
|
|
BEGIN
|
|
IF EXISTS (SELECT * FROM models EXCEPT SELECT * FROM _models_before)
|
|
OR EXISTS (SELECT * FROM _models_before EXCEPT SELECT * FROM models)
|
|
OR EXISTS (SELECT * FROM {LEDGER} EXCEPT SELECT * FROM _model_ledger_before)
|
|
OR EXISTS (SELECT * FROM _model_ledger_before EXCEPT SELECT * FROM {LEDGER})
|
|
THEN RAISE EXCEPTION 'Exact model migration repeat changed stored rows'; END IF;
|
|
END $model_repeat$;
|
|
SELECT json_build_object('check','exact_repeat_is_noop','passed',true);
|
|
SELECT json_build_object('check','existing_embedding_and_credentials_unchanged','passed',
|
|
EXISTS (SELECT 1 FROM _models_before WHERE type='Embedding') AND NOT EXISTS (
|
|
SELECT 1 FROM _models_before b LEFT JOIN models m ON m.id=b.id
|
|
WHERE to_jsonb(m) IS DISTINCT FROM to_jsonb(b)));
|
|
"""
|
|
for name, mutation, expected in cases:
|
|
sql += rejected_case(name, mutation, expected, query)
|
|
sql += f"""
|
|
UPDATE models SET display_name='Rollback-only native display edit',
|
|
parameters=jsonb_set(parameters,'{{api_key}}','"enc:v1:rollback-only-rotation"'::jsonb)
|
|
WHERE id={uid};
|
|
CREATE TEMP TABLE _model_rotated_before ON COMMIT DROP AS SELECT * FROM models;
|
|
{query}
|
|
DO $model_rotation_preservation$
|
|
BEGIN
|
|
IF EXISTS (SELECT * FROM models EXCEPT SELECT * FROM _model_rotated_before)
|
|
OR EXISTS (SELECT * FROM _model_rotated_before EXCEPT SELECT * FROM models)
|
|
THEN RAISE EXCEPTION 'Model migration overwrote native edits or rotated credentials'; END IF;
|
|
END $model_rotation_preservation$;
|
|
SELECT json_build_object('check','native_display_and_rotated_credential_preserved','passed',true);
|
|
SELECT json_build_object('check','all_model_ledger_rows_secret_free','passed',
|
|
NOT EXISTS (SELECT 1 FROM {LEDGER} WHERE source_snapshot::text LIKE '%"api_key"%'
|
|
OR source_snapshot::text LIKE '%"stored_parameters"%'
|
|
OR source_snapshot::text LIKE '%enc:v1:%'));
|
|
ROLLBACK;
|
|
"""
|
|
return sql
|
|
|
|
|
|
def pro_api_check(credentials_path):
|
|
credentials = json.loads(credentials_path.read_text())
|
|
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
login = urllib.request.Request("http://127.0.0.1:18080/api/v1/auth/login",
|
|
data=json.dumps({key: credentials[key] for key in ("email", "password")}).encode(),
|
|
headers={"Content-Type": "application/json"})
|
|
try:
|
|
with opener.open(login, timeout=20) as response:
|
|
auth = json.load(response)
|
|
except (urllib.error.URLError, ValueError):
|
|
raise RuntimeError("Acceptance verification login failed") from None
|
|
if auth.get("active_tenant", {}).get("id") != TARGET_TENANT or not auth.get("token"):
|
|
raise RuntimeError("Verification login is outside the acceptance tenant")
|
|
body = urllib.parse.urlencode({"input": "\u4ec5\u56de\u590dOK", "options": json.dumps({"max_tokens": 256})}).encode()
|
|
request = urllib.request.Request(
|
|
f"http://127.0.0.1:18080/api/v1/models/{model_id(3, 'deepseek-v4-pro')}/debug",
|
|
data=body, headers={"Content-Type": "application/x-www-form-urlencoded",
|
|
"Authorization": "Bearer " + auth["token"]})
|
|
try:
|
|
with opener.open(request, timeout=55) as response:
|
|
payload = json.load(response)
|
|
except (urllib.error.URLError, ValueError):
|
|
return {"ok": False, "finish_reason": "request_failed", "prompt_tokens": None,
|
|
"completion_tokens": None, "total_tokens": None, "elapsed_ms": None}
|
|
data = payload.get("data") or {}
|
|
raw = data.get("raw_response") or {}
|
|
usage = raw.get("usage") or {}
|
|
finish = raw.get("finish_reason")
|
|
# Whitelist the only model text field permitted in the saved result.
|
|
allowed_finish = {"stop", "length", "tool_calls", "content_filter", "function_call", None, ""}
|
|
return {"ok": bool(payload.get("success") and data.get("ok") and str(raw.get("content", "")).strip() == "OK"),
|
|
"finish_reason": finish if finish in allowed_finish else "unrecognized",
|
|
"prompt_tokens": usage.get("prompt_tokens"), "completion_tokens": usage.get("completion_tokens"),
|
|
"total_tokens": usage.get("total_tokens"), "elapsed_ms": data.get("elapsed_ms")}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--manifest", type=Path, required=True)
|
|
parser.add_argument("--sqlite", type=Path, required=True)
|
|
parser.add_argument("--environment", type=Path, required=True)
|
|
parser.add_argument("--out", type=Path, required=True)
|
|
parser.add_argument("--credentials", type=Path)
|
|
args = parser.parse_args()
|
|
if args.out.exists():
|
|
parser.error("Output already exists")
|
|
manifest = json.loads(args.manifest.read_text())
|
|
with consistent_snapshot(args.sqlite) as (db, info):
|
|
fresh = model_plan(db)
|
|
expected = {k: v for k, v in manifest.items() if k != "source_snapshot_sha256"}
|
|
if fresh != expected or info["source_mutated"]:
|
|
raise RuntimeError("Model source differs from reviewed manifest")
|
|
query = model_sql(manifest, read_credentials(db, manifest, resolve_source_credential),
|
|
native_encryptor(args.environment))
|
|
before, ledger_before = target_snapshot(), ledger_snapshot()
|
|
output = sql_run(regression_sql(manifest, query))
|
|
checks = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
|
after, ledger_after = target_snapshot(), ledger_snapshot()
|
|
checks.append({"check": "all_test_changes_rolled_back", "passed": before == after and ledger_before == ledger_after})
|
|
checks.append({"check": "acceptance_scope", "passed": before["database"] == after["database"] == TARGET_DATABASE})
|
|
report = {"checked_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
"database": TARGET_DATABASE, "tenant_id": TARGET_TENANT,
|
|
"committed": False, "checks": checks, "passed": all(c["passed"] for c in checks)}
|
|
if args.credentials:
|
|
report["pro_debug_max_tokens_256"] = pro_api_check(args.credentials)
|
|
save_json(args.out, report)
|
|
print(json.dumps(report))
|
|
return 0 if report["passed"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|