fix: 对齐多配置运行状态语义 (#149)
This commit is contained in:
@@ -25,9 +25,14 @@ Brain 报告的 `runtime.state` 和每个输入的 `state` 使用同一枚举:
|
||||
|
||||
首次有效消息可为任一状态;Sense 只校验同实例连续消息的迁移。`stale`、`offline_*` 不参与 Brain 状态迁移。恢复连接后,只有 schema、时间、sequence 和状态迁移均有效的新消息才能更新投影。
|
||||
|
||||
## 配置 revision
|
||||
## 配置流与 revision
|
||||
|
||||
`configuration.applied_revision` 只表示 Brain 已实际应用的配置。Sense 将它与自己投递成功的期望 revision 比较:相等为 synchronized,不相等为 mismatch。Sense 的期望 revision 不进入本消息,避免产生第二事实源。`not_configured` 时 revision 必须为 null;`applied` 时必须非空;`rejected` 必须带错误码。
|
||||
`configurations` 按 #148 的配置流报告,可以为空,也可以包含多个配置。每项 `config_id` 必须唯一,并与 `yovision.source-config/v1` 的 `config_id` 一致;重复 ID 使整条状态无效,不能覆盖最后已知投影。`applied_revision` 是 Brain 对该配置流已实际应用的 integer revision。Sense 必须逐个 `config_id` 与自己已投递的期望 revision 比较:相等为 synchronized,不相等为 mismatch;Sense 的期望 revision 不进入本消息,避免产生第二事实源。
|
||||
|
||||
- `not_configured`:尚未应用该配置,revision 必须为 null。
|
||||
- `applying`:正在应用;revision 为 null 或仍在运行的上一个 revision。
|
||||
- `applied`:应用成功,revision 必须是大于等于 1 的整数。
|
||||
- `rejected`:本次应用被拒绝;revision 为 null 或最后成功 revision,且必须带稳定错误码。
|
||||
|
||||
## 兼容与回退
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"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 },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": [],
|
||||
"alert": { "kind": "intrusion" }
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"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 },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 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,19 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d206",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 47,
|
||||
"observed_at": "2026-08-31T00:05:00Z",
|
||||
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null },
|
||||
{ "config_id": "gate-primary", "apply_state": "rejected", "applied_revision": 20, "error_code": "CONFIG_INVALID" }
|
||||
],
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["CONFIG_INVALID"],
|
||||
"metrics": { "load_percent": 42, "queue_depth": 1, "latency_ms": 31 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
"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 },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": []
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"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 },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": []
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"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 },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": [],
|
||||
"user_session": { "user": "forbidden" }
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"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 },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 20, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d107",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 46,
|
||||
"observed_at": "2026-08-31T00:04:30Z",
|
||||
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "new-stream", "apply_state": "not_configured", "applied_revision": null, "error_code": null },
|
||||
{ "config_id": "yard-secondary", "apply_state": "applying", "applied_revision": 8, "error_code": null },
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null },
|
||||
{ "config_id": "warehouse", "apply_state": "rejected", "applied_revision": 3, "error_code": "CONFIG_INVALID" }
|
||||
],
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["CONFIG_INVALID"],
|
||||
"metrics": { "load_percent": 42, "queue_depth": 1, "latency_ms": 31 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -6,7 +6,10 @@
|
||||
"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 },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null },
|
||||
{ "config_id": "yard-secondary", "apply_state": "applying", "applied_revision": 8, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["RESOURCE_PRESSURE"],
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d106",
|
||||
"brain_instance_ref": "brain-east-02",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "unconfigured", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [],
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 0, "queue_depth": 0, "latency_ms": 0 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
"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 },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["INPUT_UNREACHABLE"],
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"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 },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"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 },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| `observed_at` | 发布观测完成时间 | 校验未来偏差;用它推导 fresh/stale,不用接收时间覆盖 |
|
||||
| `runtime.*` | 报告真实运行状态和脱敏版本 | 校验迁移并形成只读运维状态 |
|
||||
| `model.*` | 报告逻辑模型引用及版本,不报告文件路径 | 显示版本差异,不推导模型下载或重启命令 |
|
||||
| `configuration.*` | 报告真实应用结果和 revision | 与 Sense 内部期望 revision 比较;不回写 Brain 状态 |
|
||||
| `configurations[]` | 每个 `config_id` 报告真实应用结果和 integer revision;同一消息内 ID 唯一 | 按 `config_id` 与 Sense 内部期望 revision 比较;拒绝重复 ID,不回写 Brain 状态 |
|
||||
| `health.*` | 聚合无敏感健康与有界指标 | 展示健康、指标和稳定错误码,不生成业务 Alert |
|
||||
| `inputs[]` | 按逻辑输入发布安全摘要 | 按 `input_ref` 映射运维投影,不读取视频或检测内容 |
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"observed_at",
|
||||
"runtime",
|
||||
"model",
|
||||
"configuration",
|
||||
"configurations",
|
||||
"health",
|
||||
"inputs"
|
||||
],
|
||||
@@ -44,47 +44,49 @@
|
||||
"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" } }
|
||||
"configurations": {
|
||||
"type": "array",
|
||||
"maxItems": 4096,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["config_id", "apply_state", "applied_revision", "error_code"],
|
||||
"properties": {
|
||||
"config_id": { "$ref": "#/$defs/configId" },
|
||||
"apply_state": {
|
||||
"type": "string",
|
||||
"enum": ["not_configured", "applying", "applied", "rejected"]
|
||||
},
|
||||
"then": { "properties": { "applied_revision": { "type": "null" } } }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "applied" } }
|
||||
"applied_revision": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 1
|
||||
},
|
||||
"then": { "properties": { "applied_revision": { "type": "string", "minLength": 1 } } }
|
||||
"error_code": { "$ref": "#/$defs/nullableErrorCode" }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "rejected" } }
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "not_configured" } }
|
||||
},
|
||||
"then": { "properties": { "applied_revision": { "type": "null" } } }
|
||||
},
|
||||
"then": {
|
||||
"required": ["error_code"],
|
||||
"properties": { "error_code": { "$ref": "#/$defs/errorCode" } }
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "applied" } }
|
||||
},
|
||||
"then": { "properties": { "applied_revision": { "type": "integer", "minimum": 1 } } }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "rejected" } }
|
||||
},
|
||||
"then": { "properties": { "error_code": { "$ref": "#/$defs/errorCode" } } }
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"type": "object",
|
||||
@@ -126,6 +128,12 @@
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"configId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
|
||||
},
|
||||
"logicalRef": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
python -m unittest discover -s contracts/tests/runtime-status-v1 -p "test_*.py" -v
|
||||
```
|
||||
|
||||
测试只使用 Python 标准库,不安装依赖、不访问网络。它对冻结 Schema 的已用关键字执行验证,并覆盖状态迁移、时间/陈旧边界、offline/recovery、配置 revision、未知主版本、倒序消息和敏感字段拒绝。产品 adapter 还需在各自工单中运行本地模型映射测试。
|
||||
测试只使用 Python 标准库,不安装依赖、不访问网络。它对冻结 Schema 的已用关键字执行验证,并覆盖状态迁移、时间/陈旧边界、offline/recovery、空/多配置流、四种配置应用状态、重复 `config_id`、integer revision mismatch、未知主版本、倒序消息、回退保留和敏感字段拒绝。产品 adapter 还需在各自工单中运行本地模型映射测试。
|
||||
|
||||
@@ -124,6 +124,17 @@ def load(path):
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def validate_contract(instance):
|
||||
errors = validate(instance)
|
||||
configurations = instance.get("configurations") if isinstance(instance, dict) else None
|
||||
if isinstance(configurations, list):
|
||||
config_ids = [item.get("config_id") for item in configurations if isinstance(item, dict)]
|
||||
duplicates = {config_id for config_id in config_ids if config_ids.count(config_id) > 1}
|
||||
if duplicates:
|
||||
errors.append(f"$.configurations: duplicate config_id {sorted(duplicates)!r}")
|
||||
return errors
|
||||
|
||||
|
||||
def freshness(observed_at, evaluation_time):
|
||||
age = evaluation_time - parse_datetime(observed_at)
|
||||
if age < timedelta(seconds=-30):
|
||||
@@ -148,7 +159,7 @@ def may_transition(previous, current):
|
||||
def may_replace(previous, candidate, evaluation_time):
|
||||
if candidate["schema_version"] != "yovision.runtime-status/v1":
|
||||
return False
|
||||
if validate(candidate):
|
||||
if validate_contract(candidate):
|
||||
return False
|
||||
if freshness(candidate["observed_at"], evaluation_time) == "future_rejected":
|
||||
return False
|
||||
@@ -168,29 +179,30 @@ class RuntimeStatusV1ContractTests(unittest.TestCase):
|
||||
self.assertEqual(SCHEMA["$schema"], "https://json-schema.org/draft/2020-12/schema")
|
||||
self.assertEqual(SCHEMA["properties"]["schema_version"]["const"], "yovision.runtime-status/v1")
|
||||
self.assertFalse(SCHEMA["additionalProperties"])
|
||||
for name in ("runtime", "model", "configuration", "health"):
|
||||
for name in ("runtime", "model", "health"):
|
||||
self.assertFalse(SCHEMA["properties"][name]["additionalProperties"])
|
||||
self.assertFalse(SCHEMA["properties"]["configurations"]["items"]["additionalProperties"])
|
||||
|
||||
def test_all_valid_examples_satisfy_schema(self):
|
||||
paths = sorted(VALID_DIR.glob("*.json"))
|
||||
self.assertGreaterEqual(len(paths), 5)
|
||||
self.assertGreaterEqual(len(paths), 7)
|
||||
for path in paths:
|
||||
with self.subTest(path=path.name):
|
||||
self.assertEqual(validate(load(path)), [])
|
||||
self.assertEqual(validate_contract(load(path)), [])
|
||||
|
||||
def test_all_invalid_examples_are_rejected(self):
|
||||
paths = sorted(INVALID_DIR.glob("*.json"))
|
||||
self.assertGreaterEqual(len(paths), 5)
|
||||
self.assertGreaterEqual(len(paths), 6)
|
||||
for path in paths:
|
||||
with self.subTest(path=path.name):
|
||||
self.assertNotEqual(validate(load(path)), [])
|
||||
self.assertNotEqual(validate_contract(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), [])
|
||||
self.assertEqual(validate_contract(message), [])
|
||||
|
||||
def test_state_transition_matrix(self):
|
||||
self.assertTrue(may_transition("unconfigured", "starting"))
|
||||
@@ -224,47 +236,79 @@ class RuntimeStatusV1ContractTests(unittest.TestCase):
|
||||
older = copy.deepcopy(self.running)
|
||||
older["sequence"] = self.running["sequence"] - 1
|
||||
self.assertFalse(may_replace(self.running, older, evaluation))
|
||||
duplicate = load(INVALID_DIR / "duplicate-config-id.json")
|
||||
self.assertFalse(may_replace(self.running, duplicate, parse_datetime(duplicate["observed_at"])))
|
||||
|
||||
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"])
|
||||
desired_revisions = {"gate-primary": 21}
|
||||
self.assertEqual(validate_contract(message), [])
|
||||
report = message["configurations"][0]
|
||||
self.assertNotEqual(report["applied_revision"], desired_revisions[report["config_id"]])
|
||||
self.assertNotIn("desired_revision", report)
|
||||
|
||||
def test_configuration_apply_state_invariants(self):
|
||||
cases = [
|
||||
("not_configured", "rev-forbidden", None),
|
||||
("applied", None, None),
|
||||
("rejected", "rev-old", None),
|
||||
valid_cases = [
|
||||
("not_configured", None, None),
|
||||
("applying", None, None),
|
||||
("applying", 1, None),
|
||||
("applied", 1, None),
|
||||
("rejected", None, "CONFIG_INVALID"),
|
||||
("rejected", 1, "CONFIG_INVALID"),
|
||||
]
|
||||
for apply_state, revision, error_code in cases:
|
||||
for apply_state, revision, error_code in valid_cases:
|
||||
message = copy.deepcopy(self.running)
|
||||
message["configuration"] = {
|
||||
message["configurations"] = [{
|
||||
"config_id": "gate-primary",
|
||||
"apply_state": apply_state,
|
||||
"applied_revision": revision,
|
||||
"error_code": error_code,
|
||||
}
|
||||
}]
|
||||
with self.subTest(apply_state=apply_state):
|
||||
self.assertNotEqual(validate(message), [])
|
||||
self.assertEqual(validate_contract(message), [])
|
||||
|
||||
invalid_cases = [
|
||||
("not_configured", 1, None),
|
||||
("applied", None, None),
|
||||
("rejected", 1, None),
|
||||
]
|
||||
for apply_state, revision, error_code in invalid_cases:
|
||||
message = copy.deepcopy(self.running)
|
||||
message["configurations"] = [{
|
||||
"config_id": "gate-primary",
|
||||
"apply_state": apply_state,
|
||||
"applied_revision": revision,
|
||||
"error_code": error_code,
|
||||
}]
|
||||
with self.subTest(invalid_apply_state=apply_state):
|
||||
self.assertNotEqual(validate_contract(message), [])
|
||||
|
||||
def test_empty_multiple_and_duplicate_configuration_streams(self):
|
||||
empty = load(VALID_DIR / "empty-configurations.json")
|
||||
multiple = load(VALID_DIR / "configuration-states.json")
|
||||
duplicate = load(INVALID_DIR / "duplicate-config-id.json")
|
||||
self.assertEqual(validate_contract(empty), [])
|
||||
self.assertEqual(validate_contract(multiple), [])
|
||||
self.assertEqual(len(multiple["configurations"]), 4)
|
||||
self.assertTrue(any("duplicate config_id" in error for error in validate_contract(duplicate)))
|
||||
|
||||
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)))
|
||||
self.assertTrue(any("additional property" in error for error in validate_contract(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), [])
|
||||
self.assertNotEqual(validate_contract(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[]"):
|
||||
for field in ("schema_version", "status_id", "brain_instance_ref", "sequence", "observed_at", "runtime.*", "model.*", "configurations[]", "health.*", "inputs[]"):
|
||||
self.assertIn(f"`{field}`", mapping)
|
||||
self.assertIn("Brain", mapping)
|
||||
self.assertIn("Sense", mapping)
|
||||
|
||||
Reference in New Issue
Block a user