feat: 冻结 Brain 运行状态契约 v1 (#149)
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# Brain → Sense 运行与健康状态契约 v1
|
||||
|
||||
本目录是 Brain 运行状态到 Sense 运维投影的版本化事实源。Brain 只发布脱敏状态事实;Sense 不读取 Brain 的缓存、数据库或内部运行对象,也不能借此契约执行远程命令。
|
||||
|
||||
## 消息与时间语义
|
||||
|
||||
- `schema_version` 固定为 `runtime-status/v1`。生产者必须先通过 `runtime-status.schema.json` 再发布。
|
||||
- `status_id` 是消息幂等键;`sequence` 在单个 `brain_instance_ref` 内单调递增。重复消息可忽略;小于当前已保存 sequence 的消息不得覆盖投影。
|
||||
- `observed_at` 是 Brain 完成该次观测的 UTC RFC 3339 时间,不是 Sense 的接收时间。允许最大 30 秒未来时钟偏差;超过时拒绝该消息,并保留最后已知投影。
|
||||
- Brain 的推荐发布周期是 30 秒。Sense 以 `evaluation_time - observed_at > 90 秒` 推导 `stale`;恰好 90 秒仍为 fresh。`stale` 和 `offline` 都是 Sense 的传输/时间投影,不是 Brain 写入的运行状态。
|
||||
- 未收到任何有效状态时显示 `not_received`;传输断开但最后状态未过期时显示 `offline_fresh`;传输断开或无新消息且超过 90 秒时显示 `offline_stale` / `stale`,同时保留最后已知状态及其观测时间。
|
||||
|
||||
## 状态机
|
||||
|
||||
Brain 报告的 `runtime.state` 和每个输入的 `state` 使用同一枚举:
|
||||
|
||||
| 状态 | 含义 | 允许的下一状态 |
|
||||
|---|---|---|
|
||||
| `unconfigured` | 尚无可运行配置 | `starting`, `stopped` |
|
||||
| `starting` | 已接受启动,资源准备中 | `running`, `degraded`, `failed`, `stopped` |
|
||||
| `running` | 正常提供推理 | `degraded`, `failed`, `stopped` |
|
||||
| `degraded` | 仍提供有限服务 | `running`, `failed`, `stopped` |
|
||||
| `failed` | 无法继续提供服务 | `starting`, `stopped` |
|
||||
| `stopped` | 已有序停止 | `starting`, `unconfigured` |
|
||||
|
||||
首次有效消息可为任一状态;Sense 只校验同实例连续消息的迁移。`stale`、`offline_*` 不参与 Brain 状态迁移。恢复连接后,只有 schema、时间、sequence 和状态迁移均有效的新消息才能更新投影。
|
||||
|
||||
## 配置 revision
|
||||
|
||||
`configuration.applied_revision` 只表示 Brain 已实际应用的配置。Sense 将它与自己投递成功的期望 revision 比较:相等为 synchronized,不相等为 mismatch。Sense 的期望 revision 不进入本消息,避免产生第二事实源。`not_configured` 时 revision 必须为 null;`applied` 时必须非空;`rejected` 必须带错误码。
|
||||
|
||||
## 兼容与回退
|
||||
|
||||
- v1 字段语义冻结,未知字段被拒绝。新增可选字段或错误码前必须更新本契约及双方测试;改变字段语义或删除字段发布新主版本。
|
||||
- 消费者必须按 `schema_version` 先分派到对应版本验证器。未知主版本停止摄取并记录 `UNSUPPORTED_SCHEMA_VERSION`,不得清空或覆盖最后已知投影。
|
||||
- 回退时 Sense 停止摄取新版本,继续使用上一冻结版本的 adapter 和最后已知投影。回退不触发 Brain 重启或运行态修改。
|
||||
|
||||
## 安全边界
|
||||
|
||||
只允许 Schema 列出的字段。逻辑引用不允许 `/` 或 `\\`,因此不能携带绝对路径。消息不得包含凭据/token、堆栈、内部路径、用户会话、客户视频/图像、人脸信息或业务 Alert。结构化错误只传稳定错误码,不传自由文本错误详情。
|
||||
|
||||
错误码、映射责任和可复制验证分别见 `error-codes.md`、`mapping.md` 与 `../../tests/runtime-status-v1/README.md`。
|
||||
@@ -0,0 +1,16 @@
|
||||
# v1 稳定错误码
|
||||
|
||||
生产者可以发布以下稳定错误码。消费者遇到符合格式但尚未认识的 v1 错误码时显示“未识别的远端错误”,保留原始代码用于排障,不把它转换成业务 Alert。
|
||||
|
||||
| 错误码 | 责任域 | 含义 |
|
||||
|---|---|---|
|
||||
| `CONFIG_INVALID` | 配置 | 配置结构或值无效 |
|
||||
| `CONFIG_REVISION_UNAVAILABLE` | 配置 | 指定 revision 无法取得 |
|
||||
| `INPUT_UNREACHABLE` | 输入 | 逻辑输入暂时不可达 |
|
||||
| `INPUT_DECODE_FAILED` | 输入 | 输入解码失败 |
|
||||
| `MODEL_LOAD_FAILED` | 模型 | 模型载入失败 |
|
||||
| `INFERENCE_FAILED` | 推理 | 推理管线失败 |
|
||||
| `RESOURCE_PRESSURE` | 运行 | 资源压力导致降级 |
|
||||
| `INTERNAL_COMPONENT_FAILED` | 运行 | 内部组件失败;不随消息暴露组件路径或堆栈 |
|
||||
|
||||
`UNSUPPORTED_SCHEMA_VERSION`、`FUTURE_OBSERVATION`、`OUT_OF_ORDER_STATUS` 与 `INVALID_STATUS_TRANSITION` 是 Sense adapter 的本地摄取错误,不由 Brain 发布。
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d205",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "1.0.0" },
|
||||
"configuration": { "apply_state": "applied", "applied_revision": "rev-1", "error_code": null },
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": [],
|
||||
"alert": { "kind": "intrusion" }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d202",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "1.0.0" },
|
||||
"configuration": { "apply_state": "applied", "applied_revision": "rev-1", "error_code": null },
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": [],
|
||||
"access_token": "forbidden-example"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schema_version": "runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d203",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "C:\\models\\private.pt", "version": "1.0.0" },
|
||||
"configuration": { "apply_state": "applied", "applied_revision": "rev-1", "error_code": null },
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schema_version": "runtime-status/v2",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d201",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "1.0.0" },
|
||||
"configuration": { "apply_state": "applied", "applied_revision": "rev-1", "error_code": null },
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d204",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "1.0.0" },
|
||||
"configuration": { "apply_state": "applied", "applied_revision": "rev-1", "error_code": null },
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": [],
|
||||
"user_session": { "user": "forbidden" }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema_version": "runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d103",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 43,
|
||||
"observed_at": "2026-08-31T00:01:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configuration": { "apply_state": "applied", "applied_revision": "sense-rev-20", "error_code": null },
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 40.0, "queue_depth": 0, "latency_ms": 22.0 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schema_version": "runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d102",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 42,
|
||||
"observed_at": "2026-08-31T00:00:30Z",
|
||||
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configuration": { "apply_state": "applied", "applied_revision": "sense-rev-21", "error_code": null },
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["RESOURCE_PRESSURE"],
|
||||
"metrics": { "load_percent": 91.5, "queue_depth": 7, "latency_ms": 115.0 }
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"input_ref": "camera-gate-01",
|
||||
"state": "degraded",
|
||||
"error_codes": ["INPUT_DECODE_FAILED"],
|
||||
"metrics": { "load_percent": 5.2, "queue_depth": 3, "latency_ms": 92.0 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema_version": "runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d104",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 44,
|
||||
"observed_at": "2026-08-31T00:01:30Z",
|
||||
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configuration": { "apply_state": "applied", "applied_revision": "sense-rev-21", "error_code": null },
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["INPUT_UNREACHABLE"],
|
||||
"metrics": { "load_percent": 30.0, "queue_depth": 1, "latency_ms": 30.0 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema_version": "runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d105",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 45,
|
||||
"observed_at": "2026-08-31T00:04:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configuration": { "apply_state": "applied", "applied_revision": "sense-rev-21", "error_code": null },
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 36.0, "queue_depth": 0, "latency_ms": 20.0 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schema_version": "runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d101",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 41,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configuration": { "apply_state": "applied", "applied_revision": "sense-rev-21", "error_code": null },
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 38.5, "queue_depth": 0, "latency_ms": 21.4 }
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"input_ref": "camera-gate-01",
|
||||
"state": "running",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 5.2, "queue_depth": 0, "latency_ms": 18.1 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Brain → Sense mapper 字段责任
|
||||
|
||||
| 契约字段 | Brain 生产者责任 | Sense 消费者投影责任 |
|
||||
|---|---|---|
|
||||
| `schema_version` | 固定发布 `runtime-status/v1` | 先按主版本分派;未知版本不覆盖最后投影 |
|
||||
| `status_id` | 每次观测生成唯一幂等键 | 去重,不把重复消息当成新观测 |
|
||||
| `brain_instance_ref` | 发布部署时分配的逻辑引用 | 映射到内部 edge node;不把它当数据库主键 |
|
||||
| `sequence` | 同实例单调递增 | 拒绝倒序消息,保留最后已知投影 |
|
||||
| `observed_at` | 发布观测完成时间 | 校验未来偏差;用它推导 fresh/stale,不用接收时间覆盖 |
|
||||
| `runtime.*` | 报告真实运行状态和脱敏版本 | 校验迁移并形成只读运维状态 |
|
||||
| `model.*` | 报告逻辑模型引用及版本,不报告文件路径 | 显示版本差异,不推导模型下载或重启命令 |
|
||||
| `configuration.*` | 报告真实应用结果和 revision | 与 Sense 内部期望 revision 比较;不回写 Brain 状态 |
|
||||
| `health.*` | 聚合无敏感健康与有界指标 | 展示健康、指标和稳定错误码,不生成业务 Alert |
|
||||
| `inputs[]` | 按逻辑输入发布安全摘要 | 按 `input_ref` 映射运维投影,不读取视频或检测内容 |
|
||||
|
||||
## 契约测试责任
|
||||
|
||||
- Brain:对所有发布消息执行 Schema 校验;覆盖各运行状态、配置应用结果、降级/失败以及敏感字段拒绝。
|
||||
- Sense:使用同一有效/无效样例;覆盖版本分派、幂等与倒序、30 秒未来偏差、90 秒陈旧边界、状态迁移、offline/recovery、revision mismatch 及回退不覆盖最后投影。
|
||||
- 协调契约:`contracts/tests/runtime-status-v1/test_contract.py` 是双方最小共同测试。产品 adapter 仍需在各自工单中增加本地模型映射测试。
|
||||
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/runtime-status/v1/runtime-status.schema.json",
|
||||
"title": "YoVision Brain runtime status v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"status_id",
|
||||
"brain_instance_ref",
|
||||
"sequence",
|
||||
"observed_at",
|
||||
"runtime",
|
||||
"model",
|
||||
"configuration",
|
||||
"health",
|
||||
"inputs"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": { "const": "runtime-status/v1" },
|
||||
"status_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
},
|
||||
"brain_instance_ref": { "$ref": "#/$defs/logicalRef" },
|
||||
"sequence": { "type": "integer", "minimum": 0 },
|
||||
"observed_at": { "type": "string", "format": "date-time" },
|
||||
"runtime": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["state", "version"],
|
||||
"properties": {
|
||||
"state": { "$ref": "#/$defs/runtimeState" },
|
||||
"version": { "$ref": "#/$defs/version" },
|
||||
"started_at": { "type": ["string", "null"], "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["model_ref", "version"],
|
||||
"properties": {
|
||||
"model_ref": { "$ref": "#/$defs/logicalRef" },
|
||||
"version": { "$ref": "#/$defs/version" }
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["apply_state", "applied_revision"],
|
||||
"properties": {
|
||||
"apply_state": {
|
||||
"type": "string",
|
||||
"enum": ["not_configured", "applying", "applied", "rejected"]
|
||||
},
|
||||
"applied_revision": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"error_code": { "$ref": "#/$defs/nullableErrorCode" }
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "not_configured" } }
|
||||
},
|
||||
"then": { "properties": { "applied_revision": { "type": "null" } } }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "applied" } }
|
||||
},
|
||||
"then": { "properties": { "applied_revision": { "type": "string", "minLength": 1 } } }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "rejected" } }
|
||||
},
|
||||
"then": {
|
||||
"required": ["error_code"],
|
||||
"properties": { "error_code": { "$ref": "#/$defs/errorCode" } }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"health": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["overall", "error_codes", "metrics"],
|
||||
"properties": {
|
||||
"overall": {
|
||||
"type": "string",
|
||||
"enum": ["healthy", "degraded", "unhealthy"]
|
||||
},
|
||||
"error_codes": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"maxItems": 32,
|
||||
"items": { "$ref": "#/$defs/errorCode" }
|
||||
},
|
||||
"metrics": { "$ref": "#/$defs/metrics" }
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"type": "array",
|
||||
"maxItems": 4096,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["input_ref", "state", "error_codes", "metrics"],
|
||||
"properties": {
|
||||
"input_ref": { "$ref": "#/$defs/logicalRef" },
|
||||
"state": { "$ref": "#/$defs/runtimeState" },
|
||||
"error_codes": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"maxItems": 16,
|
||||
"items": { "$ref": "#/$defs/errorCode" }
|
||||
},
|
||||
"metrics": { "$ref": "#/$defs/metrics" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"logicalRef": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$"
|
||||
},
|
||||
"runtimeState": {
|
||||
"type": "string",
|
||||
"enum": ["unconfigured", "starting", "running", "degraded", "failed", "stopped"]
|
||||
},
|
||||
"errorCode": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z][A-Z0-9_]{2,63}$"
|
||||
},
|
||||
"nullableErrorCode": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[A-Z][A-Z0-9_]{2,63}$"
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["load_percent", "queue_depth", "latency_ms"],
|
||||
"properties": {
|
||||
"load_percent": { "type": "number", "minimum": 0, "maximum": 100 },
|
||||
"queue_depth": { "type": "integer", "minimum": 0 },
|
||||
"latency_ms": { "type": "number", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# runtime-status/v1 契约测试
|
||||
|
||||
从仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s contracts/tests/runtime-status-v1 -p "test_*.py" -v
|
||||
```
|
||||
|
||||
测试只使用 Python 标准库,不安装依赖、不访问网络。它对冻结 Schema 的已用关键字执行验证,并覆盖状态迁移、时间/陈旧边界、offline/recovery、配置 revision、未知主版本、倒序消息和敏感字段拒绝。产品 adapter 还需在各自工单中运行本地模型映射测试。
|
||||
@@ -0,0 +1,274 @@
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TEST_DIR = Path(__file__).resolve().parent
|
||||
CONTRACT_DIR = TEST_DIR.parents[1] / "runtime-status" / "v1"
|
||||
SCHEMA = json.loads((CONTRACT_DIR / "runtime-status.schema.json").read_text(encoding="utf-8"))
|
||||
VALID_DIR = CONTRACT_DIR / "examples" / "valid"
|
||||
INVALID_DIR = CONTRACT_DIR / "examples" / "invalid"
|
||||
|
||||
|
||||
def parse_datetime(value):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("not a string")
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timezone is required")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def matches_type(value, expected):
|
||||
if expected == "null":
|
||||
return value is None
|
||||
if expected == "object":
|
||||
return isinstance(value, dict)
|
||||
if expected == "array":
|
||||
return isinstance(value, list)
|
||||
if expected == "string":
|
||||
return isinstance(value, str)
|
||||
if expected == "integer":
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
if expected == "number":
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
if expected == "boolean":
|
||||
return isinstance(value, bool)
|
||||
raise AssertionError(f"unsupported schema type in test validator: {expected}")
|
||||
|
||||
|
||||
def resolve_ref(ref):
|
||||
if not ref.startswith("#/"):
|
||||
raise AssertionError(f"external refs are not supported: {ref}")
|
||||
node = SCHEMA
|
||||
for part in ref[2:].split("/"):
|
||||
node = node[part.replace("~1", "/").replace("~0", "~")]
|
||||
return node
|
||||
|
||||
|
||||
def validate(instance, schema=None, path="$", errors=None):
|
||||
schema = SCHEMA if schema is None else schema
|
||||
errors = [] if errors is None else errors
|
||||
if "$ref" in schema:
|
||||
return validate(instance, resolve_ref(schema["$ref"]), path, errors)
|
||||
|
||||
for subschema in schema.get("allOf", []):
|
||||
validate(instance, subschema, path, errors)
|
||||
if "if" in schema:
|
||||
condition_errors = validate(instance, schema["if"], path, [])
|
||||
branch = schema.get("then") if not condition_errors else schema.get("else")
|
||||
if branch is not None:
|
||||
validate(instance, branch, path, errors)
|
||||
|
||||
if "type" in schema:
|
||||
allowed = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
|
||||
if not any(matches_type(instance, expected) for expected in allowed):
|
||||
errors.append(f"{path}: expected {allowed}")
|
||||
return errors
|
||||
|
||||
if "const" in schema and instance != schema["const"]:
|
||||
errors.append(f"{path}: expected constant {schema['const']!r}")
|
||||
if "enum" in schema and instance not in schema["enum"]:
|
||||
errors.append(f"{path}: value is not in enum")
|
||||
|
||||
if isinstance(instance, dict):
|
||||
required = schema.get("required", [])
|
||||
for name in required:
|
||||
if name not in instance:
|
||||
errors.append(f"{path}: missing required property {name}")
|
||||
properties = schema.get("properties", {})
|
||||
if schema.get("additionalProperties") is False:
|
||||
for name in instance:
|
||||
if name not in properties:
|
||||
errors.append(f"{path}: additional property {name}")
|
||||
for name, value in instance.items():
|
||||
if name in properties:
|
||||
validate(value, properties[name], f"{path}.{name}", errors)
|
||||
|
||||
if isinstance(instance, list):
|
||||
if "maxItems" in schema and len(instance) > schema["maxItems"]:
|
||||
errors.append(f"{path}: too many items")
|
||||
if schema.get("uniqueItems"):
|
||||
encoded = [json.dumps(item, sort_keys=True) for item in instance]
|
||||
if len(encoded) != len(set(encoded)):
|
||||
errors.append(f"{path}: duplicate items")
|
||||
if "items" in schema:
|
||||
for index, value in enumerate(instance):
|
||||
validate(value, schema["items"], f"{path}[{index}]", errors)
|
||||
|
||||
if isinstance(instance, str):
|
||||
if "minLength" in schema and len(instance) < schema["minLength"]:
|
||||
errors.append(f"{path}: string is too short")
|
||||
if "maxLength" in schema and len(instance) > schema["maxLength"]:
|
||||
errors.append(f"{path}: string is too long")
|
||||
if "pattern" in schema and re.fullmatch(schema["pattern"], instance) is None:
|
||||
errors.append(f"{path}: pattern mismatch")
|
||||
if schema.get("format") == "date-time":
|
||||
try:
|
||||
parse_datetime(instance)
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"{path}: invalid date-time")
|
||||
|
||||
if isinstance(instance, (int, float)) and not isinstance(instance, bool):
|
||||
if "minimum" in schema and instance < schema["minimum"]:
|
||||
errors.append(f"{path}: below minimum")
|
||||
if "maximum" in schema and instance > schema["maximum"]:
|
||||
errors.append(f"{path}: above maximum")
|
||||
return errors
|
||||
|
||||
|
||||
def load(path):
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def freshness(observed_at, evaluation_time):
|
||||
age = evaluation_time - parse_datetime(observed_at)
|
||||
if age < timedelta(seconds=-30):
|
||||
return "future_rejected"
|
||||
return "stale" if age > timedelta(seconds=90) else "fresh"
|
||||
|
||||
|
||||
ALLOWED_TRANSITIONS = {
|
||||
"unconfigured": {"starting", "stopped"},
|
||||
"starting": {"running", "degraded", "failed", "stopped"},
|
||||
"running": {"degraded", "failed", "stopped"},
|
||||
"degraded": {"running", "failed", "stopped"},
|
||||
"failed": {"starting", "stopped"},
|
||||
"stopped": {"starting", "unconfigured"},
|
||||
}
|
||||
|
||||
|
||||
def may_transition(previous, current):
|
||||
return previous == current or current in ALLOWED_TRANSITIONS[previous]
|
||||
|
||||
|
||||
def may_replace(previous, candidate, evaluation_time):
|
||||
if candidate["schema_version"] != "runtime-status/v1":
|
||||
return False
|
||||
if validate(candidate):
|
||||
return False
|
||||
if freshness(candidate["observed_at"], evaluation_time) == "future_rejected":
|
||||
return False
|
||||
if candidate["brain_instance_ref"] != previous["brain_instance_ref"]:
|
||||
return False
|
||||
if candidate["sequence"] <= previous["sequence"]:
|
||||
return False
|
||||
return may_transition(previous["runtime"]["state"], candidate["runtime"]["state"])
|
||||
|
||||
|
||||
class RuntimeStatusV1ContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.running = load(VALID_DIR / "running.json")
|
||||
|
||||
def test_schema_declares_frozen_version_and_closed_objects(self):
|
||||
self.assertEqual(SCHEMA["$schema"], "https://json-schema.org/draft/2020-12/schema")
|
||||
self.assertEqual(SCHEMA["properties"]["schema_version"]["const"], "runtime-status/v1")
|
||||
self.assertFalse(SCHEMA["additionalProperties"])
|
||||
for name in ("runtime", "model", "configuration", "health"):
|
||||
self.assertFalse(SCHEMA["properties"][name]["additionalProperties"])
|
||||
|
||||
def test_all_valid_examples_satisfy_schema(self):
|
||||
paths = sorted(VALID_DIR.glob("*.json"))
|
||||
self.assertGreaterEqual(len(paths), 5)
|
||||
for path in paths:
|
||||
with self.subTest(path=path.name):
|
||||
self.assertEqual(validate(load(path)), [])
|
||||
|
||||
def test_all_invalid_examples_are_rejected(self):
|
||||
paths = sorted(INVALID_DIR.glob("*.json"))
|
||||
self.assertGreaterEqual(len(paths), 5)
|
||||
for path in paths:
|
||||
with self.subTest(path=path.name):
|
||||
self.assertNotEqual(validate(load(path)), [])
|
||||
|
||||
def test_every_runtime_state_is_schema_valid(self):
|
||||
for state in ALLOWED_TRANSITIONS:
|
||||
message = copy.deepcopy(self.running)
|
||||
message["runtime"]["state"] = state
|
||||
with self.subTest(state=state):
|
||||
self.assertEqual(validate(message), [])
|
||||
|
||||
def test_state_transition_matrix(self):
|
||||
self.assertTrue(may_transition("unconfigured", "starting"))
|
||||
self.assertTrue(may_transition("starting", "running"))
|
||||
self.assertTrue(may_transition("running", "degraded"))
|
||||
self.assertTrue(may_transition("degraded", "running"))
|
||||
self.assertTrue(may_transition("running", "failed"))
|
||||
self.assertTrue(may_transition("failed", "stopped"))
|
||||
self.assertFalse(may_transition("unconfigured", "running"))
|
||||
self.assertFalse(may_transition("stopped", "running"))
|
||||
|
||||
def test_stale_and_future_boundaries(self):
|
||||
observed = parse_datetime(self.running["observed_at"])
|
||||
self.assertEqual(freshness(self.running["observed_at"], observed + timedelta(seconds=90)), "fresh")
|
||||
self.assertEqual(freshness(self.running["observed_at"], observed + timedelta(seconds=91)), "stale")
|
||||
self.assertEqual(freshness(self.running["observed_at"], observed - timedelta(seconds=30)), "fresh")
|
||||
self.assertEqual(freshness(self.running["observed_at"], observed - timedelta(seconds=31)), "future_rejected")
|
||||
|
||||
def test_offline_keeps_last_known_and_recovery_replaces_it(self):
|
||||
last_known = load(VALID_DIR / "offline-last-known.json")
|
||||
evaluation = parse_datetime(last_known["observed_at"]) + timedelta(seconds=180)
|
||||
self.assertEqual(freshness(last_known["observed_at"], evaluation), "stale")
|
||||
self.assertEqual(last_known["runtime"]["state"], "degraded")
|
||||
recovered = load(VALID_DIR / "recovered.json")
|
||||
self.assertTrue(may_replace(last_known, recovered, parse_datetime(recovered["observed_at"])))
|
||||
|
||||
def test_unknown_version_and_out_of_order_do_not_replace_projection(self):
|
||||
unknown = load(INVALID_DIR / "unknown-major.json")
|
||||
evaluation = parse_datetime(self.running["observed_at"])
|
||||
self.assertFalse(may_replace(self.running, unknown, evaluation))
|
||||
older = copy.deepcopy(self.running)
|
||||
older["sequence"] = self.running["sequence"] - 1
|
||||
self.assertFalse(may_replace(self.running, older, evaluation))
|
||||
|
||||
def test_configuration_revision_mismatch_is_consumer_derived(self):
|
||||
message = load(VALID_DIR / "config-mismatch.json")
|
||||
desired_revision = "sense-rev-21"
|
||||
self.assertEqual(validate(message), [])
|
||||
self.assertNotEqual(message["configuration"]["applied_revision"], desired_revision)
|
||||
self.assertNotIn("desired_revision", message["configuration"])
|
||||
|
||||
def test_configuration_apply_state_invariants(self):
|
||||
cases = [
|
||||
("not_configured", "rev-forbidden", None),
|
||||
("applied", None, None),
|
||||
("rejected", "rev-old", None),
|
||||
]
|
||||
for apply_state, revision, error_code in cases:
|
||||
message = copy.deepcopy(self.running)
|
||||
message["configuration"] = {
|
||||
"apply_state": apply_state,
|
||||
"applied_revision": revision,
|
||||
"error_code": error_code,
|
||||
}
|
||||
with self.subTest(apply_state=apply_state):
|
||||
self.assertNotEqual(validate(message), [])
|
||||
|
||||
def test_sensitive_and_business_fields_are_rejected_by_name(self):
|
||||
for forbidden in ("access_token", "password", "credential", "internal_path", "stack", "user_session", "video", "face", "alert"):
|
||||
message = copy.deepcopy(self.running)
|
||||
message[forbidden] = "forbidden"
|
||||
with self.subTest(forbidden=forbidden):
|
||||
self.assertTrue(any("additional property" in error for error in validate(message)))
|
||||
|
||||
def test_logical_references_reject_paths(self):
|
||||
for value in ("C:\\models\\private.pt", "/srv/models/private.pt", "../private.pt"):
|
||||
message = copy.deepcopy(self.running)
|
||||
message["model"]["model_ref"] = value
|
||||
with self.subTest(value=value):
|
||||
self.assertNotEqual(validate(message), [])
|
||||
|
||||
def test_mapper_responsibilities_are_documented(self):
|
||||
mapping = (CONTRACT_DIR / "mapping.md").read_text(encoding="utf-8")
|
||||
for field in ("schema_version", "status_id", "brain_instance_ref", "sequence", "observed_at", "runtime.*", "model.*", "configuration.*", "health.*", "inputs[]"):
|
||||
self.assertIn(f"`{field}`", mapping)
|
||||
self.assertIn("Brain", mapping)
|
||||
self.assertIn("Sense", mapping)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user