Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aca22f4667 | ||
|
|
88b92d8811 | ||
|
|
325e6ec255 | ||
|
|
e3cb1c5bc5 | ||
|
|
7e66fab042 | ||
|
|
236964d242 | ||
|
|
5b42f58f23 | ||
|
|
8b11b39093 | ||
|
|
31b08454f9 | ||
|
|
cefa6275bf | ||
|
|
5ab212cb5c | ||
|
|
b9e21a95d1 | ||
|
|
d029067aa7 | ||
|
|
d355f3d069 | ||
|
|
71e5e6e599 | ||
|
|
c3cfaff614 | ||
|
|
707ca8e508 | ||
|
|
403fd6e554 | ||
|
|
5d222436f1 | ||
|
|
3af6a16740 | ||
|
|
8ebb8ced49 | ||
|
|
2b4ec7053b | ||
|
|
8f4717a322 | ||
|
|
951508b8b4 | ||
|
|
a8f50cf79f | ||
|
|
12857fdf32 | ||
|
|
677ed732f7 | ||
|
|
25357723a0 | ||
|
|
b7a4a0faf8 | ||
|
|
872b6c9a6c | ||
|
|
6f65121987 | ||
|
|
6757ef2c05 | ||
|
|
a0d239811f | ||
|
|
0d3d7a22ed | ||
|
|
20b06884ad | ||
|
|
6402d4384d | ||
|
|
e8121a6a8e | ||
|
|
907d50d343 | ||
|
|
3c1cce9d5f | ||
|
|
31ac2c40ac | ||
|
|
36786723e3 | ||
|
|
8abff0a566 | ||
|
|
0b40cf16c3 | ||
|
|
997e515152 | ||
|
|
c188c4c9e0 | ||
|
|
a9323bc9de | ||
|
|
b6a752c014 | ||
|
|
2ff7ff5618 | ||
|
|
04231d006a | ||
|
|
8a4514076e | ||
|
|
d517d517d9 | ||
|
|
38c12bb5b7 | ||
|
|
d332797fcb | ||
|
|
14d29a47d1 | ||
|
|
b70df147ea | ||
|
|
1c63143106 | ||
|
|
cc47e14638 | ||
|
|
461b823570 | ||
|
|
885dd0143e | ||
|
|
11e675d45c | ||
|
|
5db37c7142 | ||
|
|
9567838045 | ||
|
|
08692e33e9 | ||
|
|
94ae2f0048 | ||
|
|
c9251daadf | ||
|
|
56c07427c8 | ||
|
|
fd16f77b95 | ||
|
|
155c927a62 | ||
|
|
3db1cf94e3 | ||
|
|
aa62147702 | ||
|
|
5208891e02 | ||
|
|
bafd8d983e | ||
|
|
e28070dd03 | ||
|
|
ef0e76b201 | ||
|
|
43c5c18eec | ||
|
|
f85dbecf93 | ||
|
|
4be15f2452 | ||
|
|
06de7d0399 | ||
|
|
968de2783c | ||
|
|
e2e75694cd | ||
|
|
a800068b8a | ||
|
|
bb1010c09f | ||
|
|
58a0056b1b | ||
|
|
fca32413fa | ||
|
|
6b18857305 | ||
|
|
d5cb2349cf | ||
|
|
c7d0ce3ed9 | ||
|
|
ec9ae76a0d | ||
|
|
697e652e9b | ||
|
|
5ed7397ff0 | ||
|
|
ac92d04d71 | ||
|
|
edde69ac8d | ||
|
|
de2f1027ec | ||
|
|
547232b7b8 | ||
|
|
8115f584dd | ||
|
|
e96cc2fbb5 | ||
|
|
e0b0bfafeb | ||
|
|
e18a1ea83b | ||
|
|
d36b785f67 | ||
|
|
122cde4938 | ||
|
|
9a82b17b10 | ||
|
|
cce38003cb | ||
|
|
618174cbf2 | ||
|
|
5251199205 | ||
|
|
2715e557a9 | ||
|
|
f43a4efa80 | ||
|
|
b643e42535 | ||
|
|
32b9859a40 | ||
|
|
7ee33d27f7 | ||
|
|
6362d35868 | ||
|
|
3a81cc44b4 | ||
|
|
633a906778 |
@@ -6,6 +6,9 @@ agent_sessions.txt
|
|||||||
gitea.env
|
gitea.env
|
||||||
gitea.env.*
|
gitea.env.*
|
||||||
!gitea.env.example
|
!gitea.env.example
|
||||||
|
ip_camera.env
|
||||||
|
ip_camera.env.*
|
||||||
|
!ip_camera.env.example
|
||||||
|
|
||||||
# 常见构建与测试缓存
|
# 常见构建与测试缓存
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
YoVision 是智能视频事件平台:统一接入 ONVIF/RTSP 摄像头与后续异构传感器,完成检测、规则判定、事件留证和分级预警。
|
YoVision 是智能视频事件平台:统一接入 ONVIF/RTSP 摄像头与后续异构传感器,完成检测、规则判定、事件留证和分级预警。
|
||||||
|
|
||||||
当前为 **M0:摄像头兼容性验证与需求定稿**。默认交付 16 路,单站点按 32/64/128 路横向扩展;16 只能是默认配额,不能成为代码、数据库、数组、分页或批量操作的硬上限。
|
当前已完成 M0、M1 和 M2 的本地基线,进入 **M3:Bell/Brain 最小事件闭环**。默认交付 16 路,单站点按 32/64/128 路横向扩展;16 只能是默认配额,不能成为代码、数据库、数组、分页或批量操作的硬上限。
|
||||||
|
|
||||||
## 固定阅读顺序
|
## 固定阅读顺序
|
||||||
|
|
||||||
@@ -83,6 +83,9 @@ Gitea 不可用时,只能继续已经确认属于自己的任务;不得领
|
|||||||
python scripts/validate_agent_context.py
|
python scripts/validate_agent_context.py
|
||||||
python -m unittest discover -s tests -p "test_*.py"
|
python -m unittest discover -s tests -p "test_*.py"
|
||||||
python scripts/validate_harness_governance.py
|
python scripts/validate_harness_governance.py
|
||||||
|
go -C Bell test ./...
|
||||||
|
go -C Bell vet ./...
|
||||||
|
go -C Bell build ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
代码出现后,还必须执行 `docs/03-tech-stack.md` 中与本任务命中的模块测试;完整门禁、设备验收和容量验收按任务文件触发。提交前检查 `git status --short`、`git diff`、`git diff --cached` 与 `git diff --check`。
|
代码出现后,还必须执行 `docs/03-tech-stack.md` 中与本任务命中的模块测试;完整门禁、设备验收和容量验收按任务文件触发。提交前检查 `git status --short`、`git diff`、`git diff --cached` 与 `git diff --check`。
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Bell 事件存储基础
|
||||||
|
|
||||||
|
Bell 当前只实现 M3 的事件域基础,不包含可部署 HTTP 服务:
|
||||||
|
|
||||||
|
- Bell 在可信 ingress 内为不含 `id` 的候选事实生成 `evt_` ULID。
|
||||||
|
- 最终事件同时通过冻结 v0.1 JSON Schema 与六项代码级断言。
|
||||||
|
- PostgreSQL `bell.events` 保存不可变事实;后续 outcome 追加到 `bell.event_outcomes`。
|
||||||
|
- `bell_runtime` 只拥有两张表的 `SELECT/INSERT`,没有 `UPDATE/DELETE/TRUNCATE` 或 migration owner 权限。
|
||||||
|
|
||||||
|
Brain→Bell transport、认证、公共事件 API、规则、Alert 和证据对象存储仍需后续任务冻结,不能把 `internal/event` 的 Go 类型当成公共网络协议。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go -C Bell mod download
|
||||||
|
go -C Bell test ./...
|
||||||
|
go -C Bell vet ./...
|
||||||
|
go -C Bell build ./...
|
||||||
|
./scripts/test_postgres.ps1 -PgRoot D:\pgsql17
|
||||||
|
```
|
||||||
|
|
||||||
|
隔离 PostgreSQL harness 会创建临时 `bell_runtime` 登录成员,运行真实 repository、幂等冲突和不可变权限测试,然后停止并删除临时集群;不会读取或修改现有 `D:\pgsql17\data` 或 5432 服务。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Package contracts embeds the frozen event contract used by Bell.
|
||||||
|
package contracts
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
// EventV01Schema is byte-identical to docs/raw/contracts/event-v0.1.schema.json.
|
||||||
|
//
|
||||||
|
//go:embed event-v0.1.schema.json
|
||||||
|
var EventV01Schema []byte
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://yovision.local/contracts/event-v0.1.schema.json",
|
||||||
|
"title": "YoVision Event Instance v0.1",
|
||||||
|
"description": "推理侧 → 平台侧的唯一契约。冻结于 2026-08-03。所有顶层键必须存在(可为 null),不允许省略——省略与显式 null 无法区分,是这类系统最常见的排查陷阱。",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"id",
|
||||||
|
"source_event_id",
|
||||||
|
"tenant_id",
|
||||||
|
"site_id",
|
||||||
|
"device_id",
|
||||||
|
"sensors",
|
||||||
|
"kind",
|
||||||
|
"severity",
|
||||||
|
"confidence",
|
||||||
|
"occurred_at",
|
||||||
|
"detected_at",
|
||||||
|
"latency_seconds",
|
||||||
|
"config_version",
|
||||||
|
"rule",
|
||||||
|
"subject",
|
||||||
|
"observation",
|
||||||
|
"evidence",
|
||||||
|
"dedup_key",
|
||||||
|
"aggregated_into",
|
||||||
|
"outcome",
|
||||||
|
"outcome_source",
|
||||||
|
"outcome_reason",
|
||||||
|
"diagnostics",
|
||||||
|
"ext"
|
||||||
|
],
|
||||||
|
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"description": "契约版本。破坏性变更必须递增主版本。",
|
||||||
|
"const": "0.1"
|
||||||
|
},
|
||||||
|
|
||||||
|
"id": {
|
||||||
|
"description": "平台侧生成的全局唯一事件 ID(ULID)。推理侧不得自行生成。",
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$"
|
||||||
|
},
|
||||||
|
|
||||||
|
"source_event_id": {
|
||||||
|
"description": "推理侧原始事件 ID,如 silver_pose 的 FALL-<session>-000001。用于回溯本地截图文件名(截图即按它命名)。会话内唯一,全局不保证唯一——不得用作主键。",
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z0-9_-]{1,128}$"
|
||||||
|
},
|
||||||
|
|
||||||
|
"tenant_id": { "type": "integer", "minimum": 1 },
|
||||||
|
"site_id": { "type": "integer", "minimum": 1 },
|
||||||
|
"device_id": {
|
||||||
|
"description": "主传感器的平台设备实体主键。由推理侧的 source_id 经平台映射表解析得到。事件中不得冗余 RTSP 地址或任何凭据。多传感器融合事件的完整来源见 sensors。",
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
|
||||||
|
"sensors": {
|
||||||
|
"description": "参与本次判定的全部传感器。单摄像头事件为单元素数组。恰好一个元素的 role 为 primary,且其 device_id 必须等于顶层 device_id。",
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["device_id", "modality", "role"],
|
||||||
|
"properties": {
|
||||||
|
"device_id": { "type": "integer", "minimum": 1 },
|
||||||
|
"modality": {
|
||||||
|
"description": "设备模态。决定隐私区域准入:privacy_flag 为真的区域只允许非成像模态。",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["video", "radar", "contact", "button", "wearable", "other"]
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"description": "primary=判定主依据;corroborating=佐证(如雷达判跌倒、门磁佐证无人离开)。",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["primary", "corroborating"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"kind": {
|
||||||
|
"description": "事件类型。取值登记在 contracts/README.md 的类型注册表中,新增类型不需要升 schema 版本。v0.1 已登记:fall。",
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9_]{2,63}$"
|
||||||
|
},
|
||||||
|
|
||||||
|
"severity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["low", "medium", "high", "critical"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"confidence": {
|
||||||
|
"description": "模型置信度。几何+状态机判定链路没有天然来源,必须填 null——不得用任意常量或阈值余量伪造。",
|
||||||
|
"type": ["number", "null"],
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1
|
||||||
|
},
|
||||||
|
|
||||||
|
"occurred_at": {
|
||||||
|
"description": "事发时刻(墙钟 UTC)。决定证据回捞窗口。推理侧若只有单调时钟,按 detected_at - latency_seconds 换算。",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
|
||||||
|
"detected_at": {
|
||||||
|
"description": "判定成立时刻(墙钟 UTC)。决定 SLA 计算。必须 >= occurred_at。",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
|
||||||
|
"latency_seconds": {
|
||||||
|
"description": "从可疑到确认的耗时。可由两时间戳相减,但显式存储:它是判定质量的直接指标——贴近确认窗口下限说明证据干脆,贴近上限是误报高发区,为误报排查的首选排序键。",
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
|
||||||
|
"config_version": {
|
||||||
|
"description": "产出本事件时整套判定配置的版本。粒度高于 rule.version(阈值往往是全局的),用于调参后的回归对比。不得为空串。",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
|
||||||
|
"rule": {
|
||||||
|
"description": "命中的规则实体。推理侧无规则引擎时为 null,由平台侧按 kind 反查补全。",
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "version", "code"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"version": { "type": "integer", "minimum": 1 },
|
||||||
|
"code": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"subject": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["class", "track_id", "attributes", "anon_id", "identity", "identity_status"],
|
||||||
|
"properties": {
|
||||||
|
"class": { "type": "string", "enum": ["person", "vehicle", "object"] },
|
||||||
|
"track_id": {
|
||||||
|
"description": "跟踪器内的短期标识,跨会话不保证稳定。",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"attributes": {
|
||||||
|
"description": "A 类属性(年龄段、着装等)。未启用时为空对象,不是 null。",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"anon_id": {
|
||||||
|
"description": "B+ 类 ReID 匿名标识,站点内会话级有效(≤30min),不做跨日长期关联。未启用为 null。",
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"description": "C 类人脸身份。仅在租户已授权且比对命中时非 null。",
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["person_id", "library_id", "score"],
|
||||||
|
"properties": {
|
||||||
|
"person_id": { "type": "string" },
|
||||||
|
"library_id": { "type": "string" },
|
||||||
|
"score": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"identity_status": {
|
||||||
|
"description": "必须显式。只写 null 无法区分「没开这功能」与「比对失败」,后者是需要排查的故障。",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["not_enabled", "pending", "matched", "below_threshold", "no_candidate", "timeout"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"observation": {
|
||||||
|
"description": "结构化观测。bbox/keypoint 序列是数据闭环的唯一原料——只有视频与截图无法用于训练。",
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["zone", "dwell_sec", "bbox_seq_uri", "keypoint_seq_uri", "signal_seq_uri"],
|
||||||
|
"properties": {
|
||||||
|
"zone": { "type": ["string", "null"] },
|
||||||
|
"dwell_sec": { "type": ["number", "null"], "minimum": 0 },
|
||||||
|
"bbox_seq_uri": {
|
||||||
|
"description": "视觉模态专用。非视觉事件为 null。",
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "uri"
|
||||||
|
},
|
||||||
|
"keypoint_seq_uri": {
|
||||||
|
"description": "COCO-17 关键点逐帧序列(JSONL)。视觉模态专用,P1 必补项。",
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "uri"
|
||||||
|
},
|
||||||
|
"signal_seq_uri": {
|
||||||
|
"description": "非视觉模态的结构化序列(雷达点云轨迹与多普勒、门磁状态变迁等,JSONL)。与 keypoint_seq_uri 平级——两者是各自模态的数据闭环原料,缺任一模态的序列,该模态就无法参与模型迭代。",
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "uri"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"evidence": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["snapshot_uris", "clip_uri", "clip_range"],
|
||||||
|
"properties": {
|
||||||
|
"snapshot_uris": {
|
||||||
|
"description": "证据截图。**允许为空数组**:非成像模态(雷达、门磁)产出的事件本就没有画面,隐私区域更是禁止成像。不得据此假设每个事件都有图可看——值班台 UI 必须能渲染无画面事件。文件命名只允许包含事件 ID 与日期目录,绝不得含 RTSP 地址、凭据或客户名称,文件名会出现在日志、URL 与工单中。",
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 0,
|
||||||
|
"items": { "type": "string", "format": "uri" }
|
||||||
|
},
|
||||||
|
"clip_uri": {
|
||||||
|
"description": "含 pre-roll 的证据片段。仅有截图不足以让值班员判断真假,是误报反馈闭环的前置条件。P1 必补项。",
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"format": "uri"
|
||||||
|
},
|
||||||
|
"clip_range": {
|
||||||
|
"type": ["array", "null"],
|
||||||
|
"minItems": 2,
|
||||||
|
"maxItems": 2,
|
||||||
|
"items": { "type": "string", "format": "date-time" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"dedup_key": {
|
||||||
|
"description": "跨机位/跨时间去重键,由平台侧构造。推理侧进程内按 source_event_id 的去重仍保留——它防的是同帧重复写盘,属不同层次。",
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"aggregated_into": {
|
||||||
|
"description": "被合并入的事件 ID。非 null 时本事件不独立触发告警。",
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$"
|
||||||
|
},
|
||||||
|
|
||||||
|
"outcome": {
|
||||||
|
"description": "处置结果。事件不可变,误判只能通过本字段标记,不得删改。subject_recovered 由推理侧状态机自动回传(确认后自行起身),无需等人工。",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"unknown",
|
||||||
|
"true_positive",
|
||||||
|
"false_positive",
|
||||||
|
"subject_recovered",
|
||||||
|
"duplicate",
|
||||||
|
"test"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"outcome_source": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"enum": ["auto", "manual", null]
|
||||||
|
},
|
||||||
|
|
||||||
|
"outcome_reason": { "type": ["string", "null"] },
|
||||||
|
|
||||||
|
"diagnostics": {
|
||||||
|
"description": "推理侧内部诊断量,仅用于排查,平台不得依赖其语义。单调时钟跨进程无意义,不得用于任何时间计算。",
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"fsm_state": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["NORMAL", "SUSPECT", "CONFIRMED", "RECOVERING"]
|
||||||
|
},
|
||||||
|
"suspected_at_monotonic": { "type": "number" },
|
||||||
|
"confirmed_at_monotonic": { "type": "number" },
|
||||||
|
"horizontal_angle_degrees": { "type": ["number", "null"] },
|
||||||
|
"visible_joint_count": { "type": ["integer", "null"], "minimum": 0, "maximum": 17 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"ext": {
|
||||||
|
"description": "厂商/场景扩展位。根对象 additionalProperties=false,任何未登记字段一律放这里,避免为实验性字段升版本。",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
module yovision/bell
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
|
toolchain go1.26.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
github.com/oklog/ulid/v2 v2.1.2
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
|
||||||
|
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||||
|
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
// Package event assembles and validates immutable Bell event facts.
|
||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
jsonschema "github.com/santhosh-tekuri/jsonschema/v6"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxPayloadBytes = 1 << 20
|
||||||
|
|
||||||
|
type ErrorCode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CodeInvalidJSON ErrorCode = "invalid_json"
|
||||||
|
CodePayloadTooLarge ErrorCode = "payload_too_large"
|
||||||
|
CodeUpstreamID ErrorCode = "upstream_id_forbidden"
|
||||||
|
CodeSchema ErrorCode = "schema_invalid"
|
||||||
|
CodeTimeOrder ErrorCode = "time_order_invalid"
|
||||||
|
CodeLatency ErrorCode = "latency_inconsistent"
|
||||||
|
CodeConfidence ErrorCode = "confidence_forbidden"
|
||||||
|
CodeEvidence ErrorCode = "evidence_unsafe"
|
||||||
|
CodePrimarySensor ErrorCode = "primary_sensor_invalid"
|
||||||
|
CodePrivacyDenied ErrorCode = "privacy_denied"
|
||||||
|
CodePrivacyUnavailable ErrorCode = "privacy_unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidationError exposes a stable code without returning sensitive payloads.
|
||||||
|
type ValidationError struct {
|
||||||
|
Code ErrorCode
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) Error() string { return string(e.Code) }
|
||||||
|
func (e *ValidationError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
func validationError(code ErrorCode, err error) error {
|
||||||
|
return &ValidationError{Code: code, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDGenerator is owned by Bell. Upstream candidates are not allowed to carry id.
|
||||||
|
type IDGenerator interface {
|
||||||
|
NewEventID() (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ULIDGenerator struct{}
|
||||||
|
|
||||||
|
func (ULIDGenerator) NewEventID() (string, error) {
|
||||||
|
return "evt_" + ulid.Make().String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrivacyPolicy resolves the authoritative device/Area policy. Implementations
|
||||||
|
// must fail closed when the mapping is missing or stale.
|
||||||
|
type PrivacyPolicy interface {
|
||||||
|
VideoAllowed(ctx context.Context, tenantID, siteID, deviceID int64) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvidencePolicy checks every evidence/observation URI before persistence.
|
||||||
|
type EvidencePolicy interface {
|
||||||
|
ValidateURI(rawURI string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvidenceGuard rejects reusable credentials, network endpoints and configured
|
||||||
|
// customer/tenant names from persisted evidence URIs.
|
||||||
|
type EvidenceGuard struct {
|
||||||
|
forbidden []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEvidenceGuard(forbiddenNames ...string) (*EvidenceGuard, error) {
|
||||||
|
guard := &EvidenceGuard{}
|
||||||
|
for _, name := range forbiddenNames {
|
||||||
|
name = strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("forbidden evidence name cannot be blank")
|
||||||
|
}
|
||||||
|
guard.forbidden = append(guard.forbidden, name)
|
||||||
|
}
|
||||||
|
return guard, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ipv4Like = regexp.MustCompile(`(?:^|[^0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?:[^0-9]|$)`)
|
||||||
|
|
||||||
|
func (g *EvidenceGuard) ValidateURI(rawURI string) error {
|
||||||
|
parsed, err := url.Parse(rawURI)
|
||||||
|
if err != nil || parsed.Scheme == "" {
|
||||||
|
return errors.New("evidence URI is not absolute")
|
||||||
|
}
|
||||||
|
if parsed.User != nil || parsed.Port() != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
|
return errors.New("evidence URI contains reusable connection material")
|
||||||
|
}
|
||||||
|
if host := parsed.Hostname(); host != "" && net.ParseIP(host) != nil {
|
||||||
|
return errors.New("evidence URI contains an IP address")
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(rawURI)
|
||||||
|
for _, marker := range []string{"password", "passwd", "credential", "secret", "token=", "rtsp://"} {
|
||||||
|
if strings.Contains(lower, marker) {
|
||||||
|
return errors.New("evidence URI contains a forbidden marker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ipv4Like.MatchString(lower) {
|
||||||
|
return errors.New("evidence URI contains an IPv4-like value")
|
||||||
|
}
|
||||||
|
for _, name := range g.forbidden {
|
||||||
|
if strings.Contains(lower, name) {
|
||||||
|
return errors.New("evidence URI contains a configured sensitive name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sensor struct {
|
||||||
|
DeviceID int64 `json:"device_id"`
|
||||||
|
Modality string `json:"modality"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type storedShape struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID int64 `json:"tenant_id"`
|
||||||
|
SiteID int64 `json:"site_id"`
|
||||||
|
DeviceID int64 `json:"device_id"`
|
||||||
|
SourceEventID string `json:"source_event_id"`
|
||||||
|
Sensors []Sensor `json:"sensors"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Confidence *float64 `json:"confidence"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
DetectedAt time.Time `json:"detected_at"`
|
||||||
|
LatencySeconds float64 `json:"latency_seconds"`
|
||||||
|
Observation *struct {
|
||||||
|
BBoxSeqURI *string `json:"bbox_seq_uri"`
|
||||||
|
KeypointSeqURI *string `json:"keypoint_seq_uri"`
|
||||||
|
SignalSeqURI *string `json:"signal_seq_uri"`
|
||||||
|
} `json:"observation"`
|
||||||
|
Evidence struct {
|
||||||
|
SnapshotURIs []string `json:"snapshot_uris"`
|
||||||
|
ClipURI *string `json:"clip_uri"`
|
||||||
|
} `json:"evidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is a final, schema-valid immutable fact. JSON returns a defensive copy.
|
||||||
|
type Event struct {
|
||||||
|
shape storedShape
|
||||||
|
payload []byte
|
||||||
|
digest [sha256.Size]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Event) ID() string { return e.shape.ID }
|
||||||
|
func (e Event) TenantID() int64 { return e.shape.TenantID }
|
||||||
|
func (e Event) SiteID() int64 { return e.shape.SiteID }
|
||||||
|
func (e Event) DeviceID() int64 { return e.shape.DeviceID }
|
||||||
|
func (e Event) SourceEventID() string { return e.shape.SourceEventID }
|
||||||
|
func (e Event) Kind() string { return e.shape.Kind }
|
||||||
|
func (e Event) Severity() string { return e.shape.Severity }
|
||||||
|
func (e Event) OccurredAt() time.Time { return e.shape.OccurredAt }
|
||||||
|
func (e Event) DetectedAt() time.Time { return e.shape.DetectedAt }
|
||||||
|
func (e Event) Digest() [sha256.Size]byte { return e.digest }
|
||||||
|
func (e Event) JSON() []byte { return bytes.Clone(e.payload) }
|
||||||
|
|
||||||
|
type Factory struct {
|
||||||
|
schema *jsonschema.Schema
|
||||||
|
ids IDGenerator
|
||||||
|
privacy PrivacyPolicy
|
||||||
|
evidence EvidencePolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFactory(schemaJSON []byte, ids IDGenerator, privacy PrivacyPolicy, evidence EvidencePolicy) (*Factory, error) {
|
||||||
|
if ids == nil || privacy == nil || evidence == nil {
|
||||||
|
return nil, errors.New("event factory dependencies are required")
|
||||||
|
}
|
||||||
|
schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse event schema: %w", err)
|
||||||
|
}
|
||||||
|
compiler := jsonschema.NewCompiler()
|
||||||
|
compiler.AssertFormat()
|
||||||
|
if err := compiler.AddResource("event-v0.1.schema.json", schemaDoc); err != nil {
|
||||||
|
return nil, fmt.Errorf("register event schema: %w", err)
|
||||||
|
}
|
||||||
|
compiled, err := compiler.Compile("event-v0.1.schema.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("compile event schema: %w", err)
|
||||||
|
}
|
||||||
|
return &Factory{schema: compiled, ids: ids, privacy: privacy, evidence: evidence}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create turns a producer candidate into the final stored v0.1 event. The
|
||||||
|
// candidate must contain every v0.1 field except the Bell-owned id.
|
||||||
|
func (f *Factory) Create(ctx context.Context, candidate []byte) (Event, error) {
|
||||||
|
if len(candidate) > MaxPayloadBytes {
|
||||||
|
return Event{}, validationError(CodePayloadTooLarge, nil)
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(candidate))
|
||||||
|
decoder.UseNumber()
|
||||||
|
var object map[string]any
|
||||||
|
if err := decoder.Decode(&object); err != nil || object == nil {
|
||||||
|
return Event{}, validationError(CodeInvalidJSON, err)
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
|
return Event{}, validationError(CodeInvalidJSON, errors.New("multiple JSON values"))
|
||||||
|
}
|
||||||
|
if _, exists := object["id"]; exists {
|
||||||
|
return Event{}, validationError(CodeUpstreamID, nil)
|
||||||
|
}
|
||||||
|
id, err := f.ids.NewEventID()
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, fmt.Errorf("generate Bell event id: %w", err)
|
||||||
|
}
|
||||||
|
object["id"] = id
|
||||||
|
payload, err := json.Marshal(object)
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, validationError(CodeInvalidJSON, err)
|
||||||
|
}
|
||||||
|
if len(payload) > MaxPayloadBytes {
|
||||||
|
return Event{}, validationError(CodePayloadTooLarge, nil)
|
||||||
|
}
|
||||||
|
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, validationError(CodeInvalidJSON, err)
|
||||||
|
}
|
||||||
|
if err := f.schema.Validate(instance); err != nil {
|
||||||
|
return Event{}, validationError(CodeSchema, nil)
|
||||||
|
}
|
||||||
|
var shape storedShape
|
||||||
|
if err := json.Unmarshal(payload, &shape); err != nil {
|
||||||
|
return Event{}, validationError(CodeInvalidJSON, err)
|
||||||
|
}
|
||||||
|
if err := f.assertSemantics(ctx, shape); err != nil {
|
||||||
|
return Event{}, err
|
||||||
|
}
|
||||||
|
return Event{shape: shape, payload: payload, digest: sha256.Sum256(payload)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Factory) assertSemantics(ctx context.Context, shape storedShape) error {
|
||||||
|
if shape.DetectedAt.Before(shape.OccurredAt) {
|
||||||
|
return validationError(CodeTimeOrder, nil)
|
||||||
|
}
|
||||||
|
actual := shape.DetectedAt.Sub(shape.OccurredAt).Seconds()
|
||||||
|
if math.Abs(actual-shape.LatencySeconds) >= 0.1 {
|
||||||
|
return validationError(CodeLatency, nil)
|
||||||
|
}
|
||||||
|
if shape.Confidence != nil {
|
||||||
|
return validationError(CodeConfidence, nil)
|
||||||
|
}
|
||||||
|
primary := 0
|
||||||
|
for _, sensor := range shape.Sensors {
|
||||||
|
if sensor.Role == "primary" {
|
||||||
|
primary++
|
||||||
|
if sensor.DeviceID != shape.DeviceID {
|
||||||
|
return validationError(CodePrimarySensor, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sensor.Modality == "video" {
|
||||||
|
allowed, err := f.privacy.VideoAllowed(ctx, shape.TenantID, shape.SiteID, sensor.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
return validationError(CodePrivacyUnavailable, nil)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return validationError(CodePrivacyDenied, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if primary != 1 {
|
||||||
|
return validationError(CodePrimarySensor, nil)
|
||||||
|
}
|
||||||
|
var uris []string
|
||||||
|
uris = append(uris, shape.Evidence.SnapshotURIs...)
|
||||||
|
if shape.Evidence.ClipURI != nil {
|
||||||
|
uris = append(uris, *shape.Evidence.ClipURI)
|
||||||
|
}
|
||||||
|
if shape.Observation != nil {
|
||||||
|
for _, value := range []*string{
|
||||||
|
shape.Observation.BBoxSeqURI,
|
||||||
|
shape.Observation.KeypointSeqURI,
|
||||||
|
shape.Observation.SignalSeqURI,
|
||||||
|
} {
|
||||||
|
if value != nil {
|
||||||
|
uris = append(uris, *value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rawURI := range uris {
|
||||||
|
if err := f.evidence.ValidateURI(rawURI); err != nil {
|
||||||
|
return validationError(CodeEvidence, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package event_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"yovision/bell/contracts"
|
||||||
|
"yovision/bell/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fixedEventID = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"
|
||||||
|
|
||||||
|
type fixedIDs struct{ id string }
|
||||||
|
|
||||||
|
func (f fixedIDs) NewEventID() (string, error) { return f.id, nil }
|
||||||
|
|
||||||
|
type privacy struct {
|
||||||
|
allowed bool
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *privacy) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
|
||||||
|
p.calls++
|
||||||
|
return p.allowed, p.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func contractPath(name string) string {
|
||||||
|
return filepath.Join("..", "..", "..", "docs", "raw", "contracts", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidate(t *testing.T, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(contractPath(name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var object map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &object); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
delete(object, "id")
|
||||||
|
encoded, err := json.Marshal(object)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutate(t *testing.T, raw []byte, fn func(map[string]any)) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var object map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &object); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fn(object)
|
||||||
|
encoded, err := json.Marshal(object)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func factory(t *testing.T, policy *privacy) *event.Factory {
|
||||||
|
t.Helper()
|
||||||
|
guard, err := event.NewEvidenceGuard("private-customer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
value, err := event.NewFactory(contracts.EventV01Schema, fixedIDs{id: fixedEventID}, policy, guard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCode(t *testing.T, err error, code event.ErrorCode) {
|
||||||
|
t.Helper()
|
||||||
|
var validation *event.ValidationError
|
||||||
|
if !errors.As(err, &validation) || validation.Code != code {
|
||||||
|
t.Fatalf("expected %s, got %v", code, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrozenContractCopyIsExact(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(contractPath("event-v0.1.schema.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(raw) != string(contracts.EventV01Schema) {
|
||||||
|
t.Fatal("Bell contract copy drifted from the frozen source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactoryAcceptsAllFrozenExamples(t *testing.T) {
|
||||||
|
for _, name := range []string{
|
||||||
|
"event-v0.1.example-current.json",
|
||||||
|
"event-v0.1.example-target.json",
|
||||||
|
"event-v0.1.example-radar.json",
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
policy := &privacy{allowed: true}
|
||||||
|
created, err := factory(t, policy).Create(context.Background(), candidate(t, name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if created.ID() != fixedEventID || len(created.JSON()) == 0 {
|
||||||
|
t.Fatal("Bell did not assemble the final event")
|
||||||
|
}
|
||||||
|
if name == "event-v0.1.example-radar.json" && policy.calls != 0 {
|
||||||
|
t.Fatal("non-video event unexpectedly consulted video policy")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactoryRejectsUpstreamIDAndUnknownField(t *testing.T) {
|
||||||
|
policy := &privacy{allowed: true}
|
||||||
|
base := candidate(t, "event-v0.1.example-current.json")
|
||||||
|
withID := mutate(t, base, func(object map[string]any) { object["id"] = fixedEventID })
|
||||||
|
_, err := factory(t, policy).Create(context.Background(), withID)
|
||||||
|
assertCode(t, err, event.CodeUpstreamID)
|
||||||
|
|
||||||
|
unknown := mutate(t, base, func(object map[string]any) { object["surprise"] = true })
|
||||||
|
_, err = factory(t, policy).Create(context.Background(), unknown)
|
||||||
|
assertCode(t, err, event.CodeSchema)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactoryEnforcesCrossFieldAssertions(t *testing.T) {
|
||||||
|
base := candidate(t, "event-v0.1.example-current.json")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
code event.ErrorCode
|
||||||
|
edit func(map[string]any)
|
||||||
|
}{
|
||||||
|
{"time-order", event.CodeTimeOrder, func(v map[string]any) { v["occurred_at"] = "2026-08-03T10:31:23.000Z" }},
|
||||||
|
{"latency", event.CodeLatency, func(v map[string]any) { v["latency_seconds"] = 9.0 }},
|
||||||
|
{"confidence", event.CodeConfidence, func(v map[string]any) { v["confidence"] = 0.9 }},
|
||||||
|
{"primary", event.CodePrimarySensor, func(v map[string]any) {
|
||||||
|
v["sensors"] = []any{
|
||||||
|
map[string]any{"device_id": float64(5012), "modality": "video", "role": "primary"},
|
||||||
|
map[string]any{"device_id": float64(5013), "modality": "radar", "role": "primary"},
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := factory(t, &privacy{allowed: true}).Create(context.Background(), mutate(t, base, test.edit))
|
||||||
|
assertCode(t, err, test.code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactoryFailsClosedForPrivacyAndEvidence(t *testing.T) {
|
||||||
|
base := candidate(t, "event-v0.1.example-current.json")
|
||||||
|
_, err := factory(t, &privacy{err: errors.New("mapping unavailable")}).Create(context.Background(), base)
|
||||||
|
assertCode(t, err, event.CodePrivacyUnavailable)
|
||||||
|
|
||||||
|
_, err = factory(t, &privacy{allowed: false}).Create(context.Background(), base)
|
||||||
|
assertCode(t, err, event.CodePrivacyDenied)
|
||||||
|
|
||||||
|
unsafe := mutate(t, base, func(v map[string]any) {
|
||||||
|
evidence := v["evidence"].(map[string]any)
|
||||||
|
evidence["snapshot_uris"] = []any{"rtsp://user:password@10.0.0.1:554/private-customer.png"}
|
||||||
|
})
|
||||||
|
_, err = factory(t, &privacy{allowed: true}).Create(context.Background(), unsafe)
|
||||||
|
assertCode(t, err, event.CodeEvidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactoryRequiresFailClosedPoliciesAndPayloadLimit(t *testing.T) {
|
||||||
|
guard, err := event.NewEvidenceGuard()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := event.NewFactory(contracts.EventV01Schema, fixedIDs{id: fixedEventID}, nil, guard); err == nil {
|
||||||
|
t.Fatal("nil privacy policy unexpectedly accepted")
|
||||||
|
}
|
||||||
|
_, err = factory(t, &privacy{allowed: true}).Create(context.Background(), make([]byte, event.MaxPayloadBytes+1))
|
||||||
|
assertCode(t, err, event.CodePayloadTooLarge)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"yovision/bell/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrConflict = errors.New("immutable record id conflict")
|
||||||
|
|
||||||
|
type Postgres struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenPostgres(ctx context.Context, db *sql.DB) (*Postgres, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, errors.New("postgres database is required")
|
||||||
|
}
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("ping Bell postgres: %w", err)
|
||||||
|
}
|
||||||
|
var version int64
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 3 {
|
||||||
|
return nil, errors.New("postgres Bell schema migration v3 is required")
|
||||||
|
}
|
||||||
|
var canInsert, canSelect, canUpdate, canDelete, canTruncate bool
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT
|
||||||
|
has_table_privilege(current_user, 'bell.events', 'INSERT'),
|
||||||
|
has_table_privilege(current_user, 'bell.events', 'SELECT'),
|
||||||
|
has_table_privilege(current_user, 'bell.events', 'UPDATE'),
|
||||||
|
has_table_privilege(current_user, 'bell.events', 'DELETE'),
|
||||||
|
has_table_privilege(current_user, 'bell.events', 'TRUNCATE')`).Scan(
|
||||||
|
&canInsert, &canSelect, &canUpdate, &canDelete, &canTruncate,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("inspect Bell event privileges: %w", err)
|
||||||
|
}
|
||||||
|
if !canInsert || !canSelect || canUpdate || canDelete || canTruncate {
|
||||||
|
return nil, errors.New("Bell runtime event privileges violate append-only boundary")
|
||||||
|
}
|
||||||
|
return &Postgres{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertEvent is idempotent only for the same platform ID and exact payload.
|
||||||
|
func (p *Postgres) InsertEvent(ctx context.Context, value event.Event) (bool, error) {
|
||||||
|
digest := value.Digest()
|
||||||
|
result, err := p.db.ExecContext(ctx, `INSERT INTO bell.events(
|
||||||
|
id, tenant_id, site_id, device_id, source_event_id, kind, severity,
|
||||||
|
occurred_at, detected_at, payload_hash, payload
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb)
|
||||||
|
ON CONFLICT (id) DO NOTHING`,
|
||||||
|
value.ID(), value.TenantID(), value.SiteID(), value.DeviceID(),
|
||||||
|
value.SourceEventID(), value.Kind(), value.Severity(), value.OccurredAt(),
|
||||||
|
value.DetectedAt(), digest[:], value.JSON(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("insert immutable Bell event: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("read Bell event insert result: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 1 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var existing []byte
|
||||||
|
if err := p.db.QueryRowContext(ctx, `SELECT payload_hash FROM bell.events WHERE id=$1`, value.ID()).Scan(&existing); err != nil {
|
||||||
|
return false, fmt.Errorf("read existing Bell event digest: %w", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(existing, digest[:]) {
|
||||||
|
return false, ErrConflict
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Outcome struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
Value string `json:"outcome"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Reason *string `json:"reason"`
|
||||||
|
ActorType string `json:"actor_type"`
|
||||||
|
ActorID string `json:"actor_id"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var outcomeID = regexp.MustCompile(`^out_[0-9A-HJKMNP-TV-Z]{26}$`)
|
||||||
|
var eventID = regexp.MustCompile(`^evt_[0-9A-HJKMNP-TV-Z]{26}$`)
|
||||||
|
|
||||||
|
func (o Outcome) validate() error {
|
||||||
|
if !outcomeID.MatchString(o.ID) || !eventID.MatchString(o.EventID) || o.ActorID == "" || o.OccurredAt.IsZero() {
|
||||||
|
return errors.New("invalid outcome identity")
|
||||||
|
}
|
||||||
|
validOutcome := map[string]bool{"unknown": true, "true_positive": true, "false_positive": true, "subject_recovered": true, "duplicate": true, "test": true}
|
||||||
|
if !validOutcome[o.Value] || (o.Source != "auto" && o.Source != "manual") {
|
||||||
|
return errors.New("invalid outcome value or source")
|
||||||
|
}
|
||||||
|
if o.ActorType != "user" && o.ActorType != "service" && o.ActorType != "system" {
|
||||||
|
return errors.New("invalid outcome actor type")
|
||||||
|
}
|
||||||
|
if o.Reason != nil && utf8.RuneCountInString(*o.Reason) > 500 {
|
||||||
|
return errors.New("outcome reason is too long")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendOutcome never mutates the event or an earlier outcome record.
|
||||||
|
func (p *Postgres) AppendOutcome(ctx context.Context, value Outcome) (bool, error) {
|
||||||
|
if err := value.validate(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("encode outcome: %w", err)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(encoded)
|
||||||
|
result, err := p.db.ExecContext(ctx, `INSERT INTO bell.event_outcomes(
|
||||||
|
id, event_id, outcome, outcome_source, reason, actor_type, actor_id,
|
||||||
|
occurred_at, record_hash
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
|
ON CONFLICT (id) DO NOTHING`, value.ID, value.EventID, value.Value, value.Source,
|
||||||
|
value.Reason, value.ActorType, value.ActorID, value.OccurredAt, digest[:])
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("append Bell event outcome: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("read Bell outcome insert result: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 1 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var existing []byte
|
||||||
|
if err := p.db.QueryRowContext(ctx, `SELECT record_hash FROM bell.event_outcomes WHERE id=$1`, value.ID).Scan(&existing); err != nil {
|
||||||
|
return false, fmt.Errorf("read existing Bell outcome digest: %w", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(existing, digest[:]) {
|
||||||
|
return false, ErrConflict
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
|
"yovision/bell/contracts"
|
||||||
|
"yovision/bell/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
type storeIDs struct{ id string }
|
||||||
|
|
||||||
|
func (f storeIDs) NewEventID() (string, error) { return f.id, nil }
|
||||||
|
|
||||||
|
type allowVideo struct{}
|
||||||
|
|
||||||
|
func (allowVideo) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCandidate(t *testing.T, configVersion string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join("..", "..", "..", "docs", "raw", "contracts", "event-v0.1.example-current.json")
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var object map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &object); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
delete(object, "id")
|
||||||
|
object["config_version"] = configVersion
|
||||||
|
encoded, err := json.Marshal(object)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEvent(t *testing.T, configVersion string) event.Event {
|
||||||
|
t.Helper()
|
||||||
|
guard, err := event.NewEvidenceGuard()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
factory, err := event.NewFactory(
|
||||||
|
contracts.EventV01Schema,
|
||||||
|
storeIDs{id: "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"},
|
||||||
|
allowVideo{}, guard,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
value, err := factory.Create(context.Background(), testCandidate(t, configVersion))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresImmutableEventAndOutcome(t *testing.T) {
|
||||||
|
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("YOVISION_TEST_BELL_POSTGRES_DSN is not set")
|
||||||
|
}
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
repo, err := OpenPostgres(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
value := newEvent(t, "sp-v1-2026.07.20")
|
||||||
|
created, err := repo.InsertEvent(ctx, value)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("first insert: created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
created, err = repo.InsertEvent(ctx, value)
|
||||||
|
if err != nil || created {
|
||||||
|
t.Fatalf("idempotent replay: created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
if _, err := repo.InsertEvent(ctx, newEvent(t, "sp-v1-conflict")); !errors.Is(err, ErrConflict) {
|
||||||
|
t.Fatalf("expected immutable conflict, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := "confirmed by operator"
|
||||||
|
outcome := Outcome{
|
||||||
|
ID: "out_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", EventID: value.ID(),
|
||||||
|
Value: "true_positive", Source: "manual", Reason: &reason,
|
||||||
|
ActorType: "user", ActorID: "operator-1", OccurredAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
created, err = repo.AppendOutcome(ctx, outcome)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("append outcome: created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
created, err = repo.AppendOutcome(ctx, outcome)
|
||||||
|
if err != nil || created {
|
||||||
|
t.Fatalf("idempotent outcome replay: created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, `UPDATE bell.events SET kind='changed' WHERE id=$1`, value.ID()); err == nil {
|
||||||
|
t.Fatal("runtime unexpectedly updated immutable event")
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `DELETE FROM bell.event_outcomes WHERE id=$1`, outcome.ID); err == nil {
|
||||||
|
t.Fatal("runtime unexpectedly deleted immutable outcome")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/bin/
|
||||||
|
/data/
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
# Sense M1/M2 接入骨架
|
||||||
|
|
||||||
|
本目录是 YoVision Sense 的 M1/M2 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-012 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox、Control API v1、多实例调和 fencing 和孤儿受控处置。默认关闭真实 ONVIF 与公共业务路由;T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd Sense
|
||||||
|
go mod download
|
||||||
|
go generate ./internal/mtx ./internal/controlapi
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
|
go build -o bin/sense-api.exe ./cmd/sense-api
|
||||||
|
go run ./cmd/sense-api
|
||||||
|
```
|
||||||
|
|
||||||
|
Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`,SQLite 默认写入 `Sense/data/sense.db`,MediaMTX 控制 API 默认是 `http://127.0.0.1:9997`。默认运行暴露 `/healthz`、`/readyz` 和不含租户/设备标签的 `/metrics`;只有显式选择 PostgreSQL 并完成安全配置后才注册 7 个 `/api/v1` Control API 路由。
|
||||||
|
|
||||||
|
常用环境变量:
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SENSE_HTTP_ADDR` | `127.0.0.1:8080` | HTTP 监听地址 |
|
||||||
|
| `SENSE_ALLOW_NON_LOOPBACK` | `false` | 显式允许监听非回环地址;只应在可信网络及外部认证/防火墙就绪后开启 |
|
||||||
|
| `SENSE_DB_DRIVER` | `sqlite` | `sqlite` 或 `postgres`;生产切换必须显式选择 `postgres` |
|
||||||
|
| `SENSE_DB_DSN` | `file:data/sense.db` | 所选 driver 的私有 DSN;不得写入日志、文档或仓库,PostgreSQL 密码优先由 `PGPASSFILE`/环境密钥提供 |
|
||||||
|
| `SENSE_MEDIAMTX_URL` | `http://127.0.0.1:9997` | MediaMTX 控制 API;不得包含 userinfo |
|
||||||
|
| `SENSE_RECONCILE_INTERVAL` | `5s` | 对账周期 |
|
||||||
|
| `SENSE_RECONCILE_LEASE_DURATION` | `30s` | PostgreSQL due-row 租期;最大 5 分钟 |
|
||||||
|
| `SENSE_RECONCILE_OPERATION_TIMEOUT` | `20s` | 单项 ONVIF/MediaMTX deadline;必须严格短于租期 |
|
||||||
|
| `SENSE_PROBE_INTERVAL` | `10s` | path 探活周期 |
|
||||||
|
| `SENSE_INSTANCE_ID` | 随进程随机生成 | 最多 64 位低基数字符串;多实例部署建议显式注入唯一实例 ID |
|
||||||
|
| `SENSE_METRICS_ENABLED` | `true` | 是否注册低基数 Prometheus 文本 `/metrics` |
|
||||||
|
| `SENSE_ORPHAN_SCAN_ENABLED` | PostgreSQL 为 `true`,SQLite 为 `false` | 周期执行只读 MediaMTX Path 差异报告;从不自动删除 |
|
||||||
|
| `SENSE_ORPHAN_SCAN_INTERVAL` | `1m` | 孤儿只读扫描周期,最短 10 秒 |
|
||||||
|
| `SENSE_ONVIF_MODE` | `disabled` | `disabled` 或 `standard`;默认不访问真实摄像头 |
|
||||||
|
| `SENSE_ONVIF_RTSP_REWRITE_HOST` | 空 | NAT 或故障代理场景下重写 ONVIF 返回的 RTSP 主机 |
|
||||||
|
| `SENSE_ONVIF_RTSP_REWRITE_PORT` | `0` | 非零时重写 ONVIF 返回的 RTSP 端口 |
|
||||||
|
| `SENSE_ONVIF_RTSP_STRIP_QUERY` | `false` | 仅在已验证设备返回不可用查询串时显式移除;默认保留标准 URI 语义 |
|
||||||
|
| `SENSE_CONTROL_API_ENABLED` | `false` | 显式开启 Control API v1;只允许与 PostgreSQL 一起使用 |
|
||||||
|
| `SENSE_CONTROL_AUTH_MODE` | `static-sha256` | 首版外部摘要注册表适配器;token 格式不属于公共 API 契约 |
|
||||||
|
| `SENSE_CONTROL_AUTH_FILE` | 空 | 仓库外绝对路径;version 1 JSON 只保存 token SHA-256、主体、tenant、Site scope 和权限 |
|
||||||
|
| `SENSE_CONTROL_CURSOR_KEY_FILE` | 空 | 仓库外绝对路径;内容为至少 32 字节随机值的无填充 base64url |
|
||||||
|
| `SENSE_CONTROL_ALLOW_INSECURE_HTTP` | `false` | Control API 非回环明文监听的独立风险接受;正常部署应保持回环并在受控代理终止 TLS |
|
||||||
|
|
||||||
|
设备台账只保存 `env://<key>` 凭据引用。真实适配器从进程环境读取以下变量,不把秘密写入 SQLite、日志或 MediaMTX 错误:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SENSE_CREDENTIAL_<KEY>_ONVIF_USERNAME
|
||||||
|
SENSE_CREDENTIAL_<KEY>_ONVIF_PASSWORD
|
||||||
|
SENSE_CREDENTIAL_<KEY>_RTSP_USERNAME
|
||||||
|
SENSE_CREDENTIAL_<KEY>_RTSP_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
`cmd/sense-lab` 是回环实验室播种与脱敏收敛查询工具,不是已冻结的公共设备管理 API。`cmd/rtsp-fault-proxy` 只用于 T-006 控制真实上游网络路径故障。
|
||||||
|
|
||||||
|
MediaMTX `v1.19.3` 应作为独立二进制启动并只在可信网络开放 API。获取与 SHA-256 校验值见 `docs/03-tech-stack.md`。生成客户端使用固定版本工具和 vendored 官方 OpenAPI;`internal/mtx/generated/client.gen.go` 不可手改。
|
||||||
|
|
||||||
|
## T-009~T-012 PostgreSQL 17.10
|
||||||
|
|
||||||
|
初始化与增量 SQL 位于 `deploy/postgres/`,由高权限部署步骤按文件名前缀执行;Sense 进程不会自动创建角色、schema 或 Bell 对象。`bell_app` 拥有 Site/Area、配额、`capture_policy` 及两个版本化视图,`sense_app` 只能读取两个视图,不能读取或写入 Bell 源表。T-011 的 v4 schema 增加资源版本、24 小时幂等收据和 batch operation;T-012 的 v5 schema 增加数据库时钟租约、Path 历史归属及脱敏孤儿报告/处置结果。表中不保存 MediaMTX source URI。应用登录角色和密码由部署环境创建,不进入仓库。
|
||||||
|
|
||||||
|
PostgreSQL 新建设备必须携带匹配 tenant/Site 的 `area_id`。具有 `video_capture` 能力的设备在创建、移动 Area 和从 disabled 切到 enabled 时执行 Area 准入;`non_imaging_only` 拒绝成像设备但允许非成像设备。投影缺失、非法或版本回退只拒绝新变更,不关闭已有流。创建、配置修改和期望态受理都与对应脱敏 Outbox 事实同事务;停用后调和器只删除该设备的精确 MediaMTX path 并收敛为 offline,不枚举未知 path。Bell relay 尚未实现。
|
||||||
|
|
||||||
|
Windows 本机集成测试从仓库根目录执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/test_postgres.ps1 -PgRoot D:\pgsql17
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本要求冻结的 PostgreSQL `17.10`,使用 `initdb` 创建临时 trust 集群并只监听随机回环端口,重放 migration、执行权限断言和 PostgreSQL repository 测试后停止并清理。它会核对现有 `5432` listener 前后未变化,不读取或修改 `D:\pgsql17\data`。生产安装和恢复边界见 `deploy/postgres/README.md`。
|
||||||
|
|
||||||
|
选择 PostgreSQL 运行前,管理员必须已经安装 migration,并私下设置无密码回显的连接环境:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:SENSE_DB_DRIVER = 'postgres'
|
||||||
|
$env:SENSE_DB_DSN = '由部署环境私下设置'
|
||||||
|
go run ./cmd/sense-api
|
||||||
|
```
|
||||||
|
|
||||||
|
PostgreSQL 启动会检查 Sense v5 migration、当前角色对两个 Bell 投影视图和本地控制/对账表的最小权限;权限过宽、视图不可读或 schema 未安装时初始化失败。默认 SQLite 路径和 `cmd/sense-lab` 保持不变,但 SQLite 不实现生产 Area/Outbox、多实例租约或孤儿处置语义,Control API 与孤儿扫描在 SQLite 下不会启动。
|
||||||
|
|
||||||
|
### 孤儿报告与受控处置
|
||||||
|
|
||||||
|
`sense-api` 的 PostgreSQL 模式默认每分钟只读枚举一次 MediaMTX 配置 Path,并持久化汇总。`unowned` 表示没有 Sense 历史归属证据,永远不会由 Sense 删除;只有 `owned_stale` 可以进入处置候选。需要人工处置时从 `Sense/` 执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go run ./cmd/sense-orphan -mode report
|
||||||
|
go run ./cmd/sense-orphan -mode apply -scan-id scan_... -actor operator-id -confirm "DELETE scan_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
`report` 输出不含 source URI 的 JSON 摘要。`apply` 会再次读取数据库和 MediaMTX,只处理仍属于原快照的 `owned_stale`,并要求快照不超过 15 分钟、候选不超过 128 且候选占当前全部配置 Path 不超过 10%;任何门禁失败均为零删除,没有 `force`。完整流程与恢复方法见 [`../docs/runbooks/sense-reconciliation.md`](../docs/runbooks/sense-reconciliation.md)。
|
||||||
|
|
||||||
|
### 开启 Control API
|
||||||
|
|
||||||
|
复制 [`api/control-auth.example.json`](api/control-auth.example.json) 到仓库外受限目录并替换占位项。`token_sha256` 是至少 128 bit 随机 Bearer token 的 64 位小写 SHA-256,不是 token 明文;`site_ids` 支持精确 ID 或 `"*"`,权限只接受 `sense.devices.read`、`sense.devices.write`。注册表在启动时读取,轮换后需要受控重启。
|
||||||
|
|
||||||
|
另在仓库外生成 cursor key。PowerShell 示例只把 key 写入指定秘密文件,不把值打印到日志:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$cursorBytes = [byte[]]::new(32)
|
||||||
|
[Security.Cryptography.RandomNumberGenerator]::Fill($cursorBytes)
|
||||||
|
$cursorKey = [Convert]::ToBase64String($cursorBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
|
||||||
|
Set-Content -LiteralPath 'D:\private\sense-cursor.key' -Value $cursorKey -NoNewline
|
||||||
|
```
|
||||||
|
|
||||||
|
完成 PostgreSQL migration、专用登录角色和外部文件权限后,以私有环境开启:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:SENSE_DB_DRIVER = 'postgres'
|
||||||
|
$env:SENSE_DB_DSN = '由部署环境私下设置'
|
||||||
|
$env:SENSE_CONTROL_API_ENABLED = 'true'
|
||||||
|
$env:SENSE_CONTROL_AUTH_FILE = 'D:\private\sense-auth.json'
|
||||||
|
$env:SENSE_CONTROL_CURSOR_KEY_FILE = 'D:\private\sense-cursor.key'
|
||||||
|
go run ./cmd/sense-api
|
||||||
|
```
|
||||||
|
|
||||||
|
业务响应使用 `Cache-Control: no-store`;ETag 是写并发令牌,cursor 与认证 tenant/Site/筛选绑定。静态摘要文件只是首版私有部署适配器;公网/TLS、Bell 会话、JWT/OIDC 与热加载需后续任务,不能靠设置 `SENSE_CONTROL_ALLOW_INSECURE_HTTP=true` 冒充完成。
|
||||||
|
|
||||||
|
Windows 本地准备 MediaMTX(从仓库根目录执行):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$asset = "mediamtx_v1.19.3_windows_amd64.zip"
|
||||||
|
Invoke-WebRequest "https://github.com/bluenviron/mediamtx/releases/download/v1.19.3/$asset" -OutFile "$env:TEMP\$asset"
|
||||||
|
if ((Get-FileHash "$env:TEMP\$asset" -Algorithm SHA256).Hash.ToLowerInvariant() -ne "5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe") { throw "MediaMTX checksum mismatch" }
|
||||||
|
Expand-Archive "$env:TEMP\$asset" -DestinationPath "$env:TEMP\yovision-mediamtx-v1.19.3" -Force
|
||||||
|
& "$env:TEMP\yovision-mediamtx-v1.19.3\mediamtx.exe" "Sense\deploy\mediamtx.yml"
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux amd64 使用同版 `mediamtx_v1.19.3_linux_amd64.tar.gz`,SHA-256 为 `a7ba21268fccda3ebc43fdad76b87fddb85ce77e725b5cb637bca724b5394fbe`。不要把下载的二进制或摄像头凭据提交到仓库。
|
||||||
|
|
||||||
|
## T-006 Windows 集成验证
|
||||||
|
|
||||||
|
脚本会启动两套独立 MediaMTX、4 个独立 FFmpeg publisher、真实摄像头网络故障代理和 Sense,在临时目录播种 5 条期望态,执行四类恢复后再连续观察 30 分钟。脚本只输出脱敏计数与时间,不保存视频:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./Sense/scripts/t006-integration.ps1 -CameraEnv D:\path\to\ip_camera.env
|
||||||
|
```
|
||||||
|
|
||||||
|
`ip_camera.env` 必须保持在 Git 忽略范围内。调试时可把 `-ObservationMinutes` 降为 1;正式 T-006 证据必须使用默认 30 分钟,且最终 `maximum_unconverged`、`final_unconverged` 都为 0。
|
||||||
|
|
||||||
|
## T-014 本地 16 路容量基线
|
||||||
|
|
||||||
|
T-014 不访问摄像头或客户网络。脚本使用隔离 PostgreSQL v5、真实 Control API、两套 MediaMTX 和 16 个独立的 FFmpeg `-c copy` 合成 publisher,验证 17 路配额拒绝、三轮批量启停、`16 → 0 → 16` Path 收敛、固定四路故障隔离/恢复,以及 30 分钟稳定性和资源观测。
|
||||||
|
|
||||||
|
从仓库根目录执行预检、短窗口调试和正式验收:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -PreflightOnly
|
||||||
|
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -ObservationMinutes 1 -OutputPath (Join-Path $env:TEMP 'yovision-t014-smoke.json')
|
||||||
|
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -OutputPath (Join-Path $env:TEMP 'yovision-t014-formal.json')
|
||||||
|
```
|
||||||
|
|
||||||
|
只有默认不少于 30 分钟且输出 `formal_eligible=true` 的单次完整运行可作正式证据;调试窗口固定标记为 false。脚本要求 PostgreSQL 17.10、MediaMTX v1.19.3、FFmpeg 8.1.2 和 `Sense/` 模块的 Go 1.26.5,只使用随机回环端口,运行后清理临时媒体、二进制、PGDATA 和秘密文件。结果 JSON 不含 token、DSN、端口、设备 ID 或流地址。
|
||||||
|
|
||||||
|
2026-08-10 正式结果为 `1800.1 s / 180` 个 10 秒样本,最大/最终 `unconverged=0`、最终在线 Path 16、帧错误 0;完整版本、资源数据、失败记录和适用边界见 [`../docs/research/sense-16-stream-capacity.md`](../docs/research/sense-16-stream-capacity.md)。该结果只是本机低码率合成负载的软件基线,不代表真实 16 摄像头、客户网络、录像、AI/GPU、64/128 路或生产 SLA。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"principals": [
|
||||||
|
{
|
||||||
|
"token_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"subject_id": "replace-with-service-id",
|
||||||
|
"actor_type": "service",
|
||||||
|
"tenant_id": "tenant-demo",
|
||||||
|
"site_ids": ["site-demo"],
|
||||||
|
"permissions": ["sense.devices.read", "sense.devices.write"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+3766
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
listenAddress := flag.String("listen", "127.0.0.1:10554", "local listen address")
|
||||||
|
upstreamAddress := flag.String("upstream", "", "upstream host:port")
|
||||||
|
flag.Parse()
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
if *upstreamAddress == "" {
|
||||||
|
logger.Error("upstream is required")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if _, _, err := net.SplitHostPort(*upstreamAddress); err != nil {
|
||||||
|
logger.Error("upstream must be host:port")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
if err := serve(ctx, *listenAddress, *upstreamAddress); err != nil {
|
||||||
|
logger.Error("RTSP fault proxy stopped", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serve(ctx context.Context, listenAddress, upstreamAddress string) error {
|
||||||
|
listener, err := net.Listen("tcp", listenAddress)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listen: %w", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
_ = listener.Close()
|
||||||
|
}()
|
||||||
|
var connections sync.WaitGroup
|
||||||
|
defer connections.Wait()
|
||||||
|
for {
|
||||||
|
client, acceptErr := listener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("accept: %w", acceptErr)
|
||||||
|
}
|
||||||
|
connections.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer connections.Done()
|
||||||
|
proxy(client, upstreamAddress)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func proxy(client net.Conn, upstreamAddress string) {
|
||||||
|
defer client.Close()
|
||||||
|
upstream, err := net.DialTimeout("tcp", upstreamAddress, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer upstream.Close()
|
||||||
|
done := make(chan struct{}, 2)
|
||||||
|
copyOneWay := func(destination, source net.Conn) {
|
||||||
|
_, _ = io.Copy(destination, source)
|
||||||
|
done <- struct{}{}
|
||||||
|
}
|
||||||
|
go copyOneWay(upstream, client)
|
||||||
|
go copyOneWay(client, upstream)
|
||||||
|
<-done
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/auth"
|
||||||
|
"yovision/sense/internal/config"
|
||||||
|
"yovision/sense/internal/controlapi"
|
||||||
|
"yovision/sense/internal/metrics"
|
||||||
|
"yovision/sense/internal/mtx"
|
||||||
|
"yovision/sense/internal/onvif"
|
||||||
|
"yovision/sense/internal/orphan"
|
||||||
|
"yovision/sense/internal/probe"
|
||||||
|
"yovision/sense/internal/reconcile"
|
||||||
|
"yovision/sense/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
if err := run(logger); err != nil {
|
||||||
|
logger.Error("Sense stopped", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(logger *slog.Logger) error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load configuration: %w", err)
|
||||||
|
}
|
||||||
|
instanceID := cfg.InstanceID
|
||||||
|
if instanceID == "" {
|
||||||
|
instanceID, err = metrics.GenerateInstanceID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registry := metrics.New(instanceID, version)
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
repository, err := store.OpenRepository(ctx, cfg.DatabaseDriver, cfg.DatabaseDSN)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer repository.Close()
|
||||||
|
var controlHandler http.Handler
|
||||||
|
if cfg.ControlAPIEnabled {
|
||||||
|
controlStore, ok := repository.(store.ControlRepository)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("selected repository does not support Sense Control API")
|
||||||
|
}
|
||||||
|
authenticator, err := auth.LoadStaticSHA256(cfg.ControlAuthFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cursors, err := controlapi.LoadCursorCodec(cfg.ControlCursorKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
controlHandler = controlapi.NewHTTPHandler(controlStore, authenticator, cursors)
|
||||||
|
}
|
||||||
|
mediaClient, err := mtx.NewClient(cfg.MediaMTXURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials := onvif.EnvCredentials{}
|
||||||
|
var cameraAdapter onvif.Adapter = onvif.UnavailableAdapter{}
|
||||||
|
if cfg.ONVIFMode == "standard" {
|
||||||
|
cameraAdapter = onvif.NewHTTPAdapter(credentials, nil, onvif.HTTPOptions{
|
||||||
|
RTSPRewriteHost: cfg.RTSPRewriteHost,
|
||||||
|
RTSPRewritePort: cfg.RTSPRewritePort,
|
||||||
|
StripRTSPQuery: cfg.RTSPStripQuery,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
discovery := onvif.NewRouter(cameraAdapter, credentials)
|
||||||
|
reconciler := reconcile.NewWithOptions(repository, discovery, mediaClient, reconcile.Options{
|
||||||
|
InstanceID: instanceID, LeaseDuration: cfg.ReconcileLeaseDuration,
|
||||||
|
OperationTimeout: cfg.ReconcileOperationTimeout, Metrics: registry,
|
||||||
|
})
|
||||||
|
checker := probe.New(repository, mediaClient)
|
||||||
|
var orphanScanner *orphan.Manager
|
||||||
|
if cfg.OrphanScanEnabled {
|
||||||
|
orphanStore, ok := repository.(store.OrphanRepository)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("selected repository does not support orphan scanning")
|
||||||
|
}
|
||||||
|
orphanScanner = orphan.New(orphanStore, mediaClient, instanceID, registry)
|
||||||
|
}
|
||||||
|
report := func(err error) {
|
||||||
|
// Domain and MediaMTX errors intentionally omit stream URIs and credentials.
|
||||||
|
logger.Warn("background convergence error", "error", err)
|
||||||
|
}
|
||||||
|
var background sync.WaitGroup
|
||||||
|
startBackground := func(run func()) {
|
||||||
|
background.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer background.Done()
|
||||||
|
run()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
startBackground(func() { reconciler.Run(ctx, cfg.ReconcileInterval, report) })
|
||||||
|
startBackground(func() { checker.Run(ctx, cfg.ProbeInterval, report) })
|
||||||
|
if orphanScanner != nil {
|
||||||
|
startBackground(func() { orphanScanner.Run(ctx, cfg.OrphanScanInterval, report) })
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = writer.Write([]byte(`{"status":"ok"}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /readyz", func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = writer.Write([]byte(`{"status":"ready"}`))
|
||||||
|
})
|
||||||
|
if cfg.MetricsEnabled {
|
||||||
|
mux.Handle("GET /metrics", registry.Handler())
|
||||||
|
}
|
||||||
|
if cfg.ControlAPIEnabled {
|
||||||
|
mux.Handle("/api/v1/", controlHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: cfg.HTTPAddress, Handler: mux,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
serverErrors := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version,
|
||||||
|
"instance_id", instanceID,
|
||||||
|
"control_api_enabled", cfg.ControlAPIEnabled)
|
||||||
|
serverErrors <- server.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case serverErr := <-serverErrors:
|
||||||
|
if !errors.Is(serverErr, http.ErrServerClosed) {
|
||||||
|
stop()
|
||||||
|
background.Wait()
|
||||||
|
return fmt.Errorf("serve HTTP: %w", serverErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stop()
|
||||||
|
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
shutdownErr := server.Shutdown(shutdownContext)
|
||||||
|
background.Wait()
|
||||||
|
if shutdownErr != nil {
|
||||||
|
return fmt.Errorf("shutdown HTTP server: %w", shutdownErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"yovision/sense/internal/device"
|
||||||
|
"yovision/sense/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
Site manifestSite `json:"site"`
|
||||||
|
Devices []manifestDevice `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestSite struct {
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MaxVideoChannels int `json:"max_video_channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestDevice struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
SiteID string `json:"site_id"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
EndpointRef string `json:"endpoint_ref"`
|
||||||
|
CredentialRef string `json:"credential_ref"`
|
||||||
|
PathName string `json:"path_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(os.Args[1:], os.Stdout); err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string, output io.Writer) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return errors.New("usage: sense-lab <seed|status>")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "seed":
|
||||||
|
return seed(args[1:], output)
|
||||||
|
case "status":
|
||||||
|
return status(args[1:], output)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown command %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seed(args []string, output io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("seed", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(io.Discard)
|
||||||
|
dsn := flags.String("db", "", "SQLite DSN")
|
||||||
|
manifestPath := flags.String("manifest", "", "manifest JSON path")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *dsn == "" || *manifestPath == "" {
|
||||||
|
return errors.New("seed requires -db and -manifest")
|
||||||
|
}
|
||||||
|
file, err := os.Open(*manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open manifest: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
var value manifest
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(file, 1<<20))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return fmt.Errorf("decode manifest: %w", err)
|
||||||
|
}
|
||||||
|
repository, err := store.OpenSQLite(context.Background(), *dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer repository.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := repository.EnsureSite(ctx, device.Site{
|
||||||
|
TenantID: value.Site.TenantID, ID: value.Site.ID, Name: value.Site.Name,
|
||||||
|
MaxVideoChannels: value.Site.MaxVideoChannels,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, input := range value.Devices {
|
||||||
|
capabilities := make([]device.Capability, 0, len(input.Capabilities))
|
||||||
|
for _, capability := range input.Capabilities {
|
||||||
|
capabilities = append(capabilities, device.Capability(capability))
|
||||||
|
}
|
||||||
|
if err := repository.CreateDevice(ctx, device.Device{
|
||||||
|
ID: input.ID, TenantID: input.TenantID, SiteID: input.SiteID,
|
||||||
|
SerialNumber: input.SerialNumber, Name: input.Name, Modality: device.ModalityVideo,
|
||||||
|
Capabilities: capabilities, DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||||
|
EndpointRef: input.EndpointRef, CredentialRef: input.CredentialRef, PathName: input.PathName,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("create device %s: %w", input.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.NewEncoder(output).Encode(map[string]int{"seeded": len(value.Devices)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(args []string, output io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("status", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(io.Discard)
|
||||||
|
dsn := flags.String("db", "", "SQLite DSN")
|
||||||
|
expect := flags.Int("expect", -1, "expected device count")
|
||||||
|
requireConverged := flags.Bool("require-converged", false, "fail when unconverged is non-zero")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *dsn == "" {
|
||||||
|
return errors.New("status requires -db")
|
||||||
|
}
|
||||||
|
repository, err := store.OpenSQLite(context.Background(), *dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer repository.Close()
|
||||||
|
snapshot, err := repository.ConvergenceSnapshot(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(output).Encode(snapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *expect >= 0 && snapshot.Total != *expect {
|
||||||
|
return fmt.Errorf("expected %d devices, got %d", *expect, snapshot.Total)
|
||||||
|
}
|
||||||
|
if *requireConverged && snapshot.Unconverged != 0 {
|
||||||
|
return fmt.Errorf("unconverged devices: %d", snapshot.Unconverged)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// sense-orphan is a local, PostgreSQL-backed operational command. Report is
|
||||||
|
// read-only; apply requires a fresh scan ID, actor and exact confirmation.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"yovision/sense/internal/config"
|
||||||
|
"yovision/sense/internal/metrics"
|
||||||
|
"yovision/sense/internal/mtx"
|
||||||
|
"yovision/sense/internal/orphan"
|
||||||
|
"yovision/sense/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "sense-orphan failed:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
mode := flag.String("mode", "report", "report or apply")
|
||||||
|
scanID := flag.String("scan-id", "", "fresh scan ID required by apply")
|
||||||
|
actor := flag.String("actor", "", "operator identifier required by apply")
|
||||||
|
confirm := flag.String("confirm", "", "exact confirmation: DELETE <scan-id>")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(cfg.DatabaseDriver), store.DriverPostgres) {
|
||||||
|
return errors.New("sense-orphan requires SENSE_DB_DRIVER=postgres")
|
||||||
|
}
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
repository, err := store.OpenPostgres(ctx, cfg.DatabaseDSN)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer repository.Close()
|
||||||
|
media, err := mtx.NewClient(cfg.MediaMTXURL, &http.Client{Timeout: cfg.ReconcileOperationTimeout})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
instanceID := cfg.InstanceID
|
||||||
|
if instanceID == "" {
|
||||||
|
instanceID, err = metrics.GenerateInstanceID()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manager := orphan.New(repository, media, instanceID, metrics.New(instanceID, "operator"))
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
switch *mode {
|
||||||
|
case "report":
|
||||||
|
scan, err := manager.Report(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return encoder.Encode(struct {
|
||||||
|
ScanID string `json:"scan_id"`
|
||||||
|
Observed int `json:"observed"`
|
||||||
|
OwnedStale int `json:"owned_stale"`
|
||||||
|
Unowned int `json:"unowned"`
|
||||||
|
SafetyAllowed bool `json:"safety_allowed"`
|
||||||
|
SafetyReason string `json:"safety_reason"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}{
|
||||||
|
ScanID: scan.ID, Observed: scan.ObservedCount,
|
||||||
|
OwnedStale: scan.OwnedStaleCount, Unowned: scan.UnownedCount,
|
||||||
|
SafetyAllowed: scan.SafetyAllowed, SafetyReason: scan.SafetyReason,
|
||||||
|
ExpiresAt: scan.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
})
|
||||||
|
case "apply":
|
||||||
|
result, err := manager.Apply(ctx, *scanID, *actor, *confirm)
|
||||||
|
if encodeErr := encoder.Encode(result); encodeErr != nil {
|
||||||
|
return encodeErr
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
return errors.New("mode must be report or apply")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# T-006-only publisher fixture. This is a separate MediaMTX instance; Sense
|
||||||
|
# never edits these source paths. Each path has one independent FFmpeg process.
|
||||||
|
logLevel: warn
|
||||||
|
rtspAddress: 127.0.0.1:8555
|
||||||
|
rtspTransports: [tcp]
|
||||||
|
api: false
|
||||||
|
metrics: false
|
||||||
|
rtmp: false
|
||||||
|
hls: false
|
||||||
|
webrtc: false
|
||||||
|
srt: false
|
||||||
|
moq: false
|
||||||
|
paths:
|
||||||
|
synthetic-1:
|
||||||
|
source: publisher
|
||||||
|
synthetic-2:
|
||||||
|
source: publisher
|
||||||
|
synthetic-3:
|
||||||
|
source: publisher
|
||||||
|
synthetic-4:
|
||||||
|
source: publisher
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# MediaMTX v1.19.3 minimal control-plane configuration for local T-003 work.
|
||||||
|
# Keep the API on loopback. Production authentication/network policy is a
|
||||||
|
# later deployment concern and must be in place before any non-loopback bind.
|
||||||
|
logLevel: info
|
||||||
|
api: true
|
||||||
|
apiAddress: 127.0.0.1:9997
|
||||||
|
metrics: true
|
||||||
|
metricsAddress: 127.0.0.1:9998
|
||||||
|
paths: {}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
module yovision/sense
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
|
toolchain go1.26.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
github.com/oapi-codegen/runtime v1.6.0
|
||||||
|
modernc.org/sqlite v1.54.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/getkin/kin-openapi v0.142.0 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.23.1 // indirect
|
||||||
|
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect
|
||||||
|
github.com/oasdiff/yaml v0.1.1 // indirect
|
||||||
|
github.com/oasdiff/yaml3 v0.0.14 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
|
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
|
||||||
|
github.com/speakeasy-api/openapi v1.24.0 // indirect
|
||||||
|
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/mod v0.38.0 // indirect
|
||||||
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.40.0 // indirect
|
||||||
|
golang.org/x/tools v0.48.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.74.1 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
||||||
+234
@@ -0,0 +1,234 @@
|
|||||||
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
|
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||||
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||||
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||||
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w=
|
||||||
|
github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
|
||||||
|
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
|
||||||
|
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
|
||||||
|
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
|
||||||
|
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
|
||||||
|
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
|
||||||
|
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||||
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
|
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
|
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
|
||||||
|
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
|
||||||
|
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM=
|
||||||
|
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo=
|
||||||
|
github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=
|
||||||
|
github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
|
||||||
|
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
|
||||||
|
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
|
||||||
|
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
|
||||||
|
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
|
||||||
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||||
|
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||||
|
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||||
|
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
|
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||||
|
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||||
|
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||||
|
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
||||||
|
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
|
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||||
|
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||||
|
github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU=
|
||||||
|
github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI=
|
||||||
|
github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04=
|
||||||
|
github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg=
|
||||||
|
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||||
|
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
|
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||||
|
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
|
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||||
|
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||||
|
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||||
|
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Package auth defines the verified principal boundary for Sense HTTP APIs.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PermissionDevicesRead = "sense.devices.read"
|
||||||
|
PermissionDevicesWrite = "sense.devices.write"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnauthenticated = errors.New("unauthenticated")
|
||||||
|
logicalIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Principal struct {
|
||||||
|
SubjectID string
|
||||||
|
ActorType string
|
||||||
|
TenantID string
|
||||||
|
SiteIDs []string
|
||||||
|
Permissions map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Principal) AllowsSite(siteID string) bool {
|
||||||
|
for _, allowed := range p.SiteIDs {
|
||||||
|
if allowed == "*" || allowed == siteID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Principal) Has(permission string) bool {
|
||||||
|
_, ok := p.Permissions[permission]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
type Authenticator interface {
|
||||||
|
Authenticate(context.Context, string) (Principal, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticFile struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Principals []staticPrincipal `json:"principals"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticPrincipal struct {
|
||||||
|
TokenSHA256 string `json:"token_sha256"`
|
||||||
|
SubjectID string `json:"subject_id"`
|
||||||
|
ActorType string `json:"actor_type"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
SiteIDs []string `json:"site_ids"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticEntry struct {
|
||||||
|
digest [sha256.Size]byte
|
||||||
|
principal Principal
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaticSHA256 authenticates opaque tokens against externally provisioned
|
||||||
|
// SHA-256 digests. The source file is read only during process startup.
|
||||||
|
type StaticSHA256 struct {
|
||||||
|
entries []staticEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadStaticSHA256(path string) (*StaticSHA256, error) {
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("read static authentication registry")
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(string(contents)))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
var document staticFile
|
||||||
|
if err := decoder.Decode(&document); err != nil {
|
||||||
|
return nil, errors.New("decode static authentication registry")
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||||
|
return nil, errors.New("static authentication registry has trailing JSON")
|
||||||
|
}
|
||||||
|
if document.Version != 1 || len(document.Principals) == 0 {
|
||||||
|
return nil, errors.New("static authentication registry must contain version 1 principals")
|
||||||
|
}
|
||||||
|
entries := make([]staticEntry, 0, len(document.Principals))
|
||||||
|
seenDigests := make(map[string]struct{}, len(document.Principals))
|
||||||
|
for index, value := range document.Principals {
|
||||||
|
entry, validationErr := parseStaticPrincipal(value)
|
||||||
|
if validationErr != nil {
|
||||||
|
return nil, fmt.Errorf("invalid static authentication principal %d: %w", index, validationErr)
|
||||||
|
}
|
||||||
|
if _, exists := seenDigests[value.TokenSHA256]; exists {
|
||||||
|
return nil, errors.New("duplicate static authentication token digest")
|
||||||
|
}
|
||||||
|
seenDigests[value.TokenSHA256] = struct{}{}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
return &StaticSHA256{entries: entries}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStaticPrincipal(value staticPrincipal) (staticEntry, error) {
|
||||||
|
var entry staticEntry
|
||||||
|
if len(value.TokenSHA256) != sha256.Size*2 || value.TokenSHA256 != strings.ToLower(value.TokenSHA256) {
|
||||||
|
return entry, errors.New("token_sha256 must be 64 lowercase hexadecimal characters")
|
||||||
|
}
|
||||||
|
digest, err := hex.DecodeString(value.TokenSHA256)
|
||||||
|
if err != nil {
|
||||||
|
return entry, errors.New("token_sha256 must be hexadecimal")
|
||||||
|
}
|
||||||
|
copy(entry.digest[:], digest)
|
||||||
|
if strings.TrimSpace(value.SubjectID) == "" || len(value.SubjectID) > 200 {
|
||||||
|
return entry, errors.New("subject_id must contain 1 to 200 characters")
|
||||||
|
}
|
||||||
|
if value.ActorType != "user" && value.ActorType != "service" {
|
||||||
|
return entry, errors.New("actor_type must be user or service")
|
||||||
|
}
|
||||||
|
if !logicalIDPattern.MatchString(value.TenantID) {
|
||||||
|
return entry, errors.New("tenant_id is invalid")
|
||||||
|
}
|
||||||
|
if len(value.SiteIDs) == 0 {
|
||||||
|
return entry, errors.New("site_ids must not be empty")
|
||||||
|
}
|
||||||
|
sites := make([]string, 0, len(value.SiteIDs))
|
||||||
|
seenSites := make(map[string]struct{}, len(value.SiteIDs))
|
||||||
|
for _, siteID := range value.SiteIDs {
|
||||||
|
if siteID != "*" && !logicalIDPattern.MatchString(siteID) {
|
||||||
|
return entry, errors.New("site_ids contains an invalid site")
|
||||||
|
}
|
||||||
|
if _, exists := seenSites[siteID]; exists {
|
||||||
|
return entry, errors.New("site_ids contains a duplicate")
|
||||||
|
}
|
||||||
|
seenSites[siteID] = struct{}{}
|
||||||
|
sites = append(sites, siteID)
|
||||||
|
}
|
||||||
|
permissions := make(map[string]struct{}, len(value.Permissions))
|
||||||
|
for _, permission := range value.Permissions {
|
||||||
|
if permission != PermissionDevicesRead && permission != PermissionDevicesWrite {
|
||||||
|
return entry, errors.New("permissions contains an unsupported value")
|
||||||
|
}
|
||||||
|
if _, exists := permissions[permission]; exists {
|
||||||
|
return entry, errors.New("permissions contains a duplicate")
|
||||||
|
}
|
||||||
|
permissions[permission] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(permissions) == 0 {
|
||||||
|
return entry, errors.New("permissions must not be empty")
|
||||||
|
}
|
||||||
|
entry.principal = Principal{
|
||||||
|
SubjectID: value.SubjectID, ActorType: value.ActorType, TenantID: value.TenantID,
|
||||||
|
SiteIDs: sites, Permissions: permissions,
|
||||||
|
}
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *StaticSHA256) Authenticate(_ context.Context, token string) (Principal, error) {
|
||||||
|
if len(token) < 22 || len(token) > 4096 {
|
||||||
|
return Principal{}, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256([]byte(token))
|
||||||
|
match := -1
|
||||||
|
for index := range a.entries {
|
||||||
|
if subtle.ConstantTimeCompare(digest[:], a.entries[index].digest[:]) == 1 {
|
||||||
|
match = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match < 0 {
|
||||||
|
return Principal{}, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
return a.entries[match].principal, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStaticSHA256AuthenticatesScopedPrincipal(t *testing.T) {
|
||||||
|
token := "0123456789abcdef0123456789abcdef"
|
||||||
|
digest := sha256.Sum256([]byte(token))
|
||||||
|
path := filepath.Join(t.TempDir(), "auth.json")
|
||||||
|
document := `{"version":1,"principals":[{` +
|
||||||
|
`"token_sha256":"` + hex.EncodeToString(digest[:]) + `",` +
|
||||||
|
`"subject_id":"operator-1","actor_type":"user","tenant_id":"tenant-a",` +
|
||||||
|
`"site_ids":["site-a"],"permissions":["sense.devices.read"]}]}`
|
||||||
|
if err := os.WriteFile(path, []byte(document), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
authenticator, err := LoadStaticSHA256(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
principal, err := authenticator.Authenticate(context.Background(), token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if principal.TenantID != "tenant-a" || !principal.AllowsSite("site-a") ||
|
||||||
|
principal.AllowsSite("site-b") || !principal.Has(PermissionDevicesRead) {
|
||||||
|
t.Fatalf("unexpected principal: %+v", principal)
|
||||||
|
}
|
||||||
|
if _, err := authenticator.Authenticate(context.Background(), strings.Repeat("x", 32)); err == nil {
|
||||||
|
t.Fatal("unknown token authenticated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaticSHA256RejectsUnsafeRegistry(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
`{"version":1,"principals":[]}`,
|
||||||
|
`{"version":1,"principals":[{"token_sha256":"ABC","subject_id":"x","actor_type":"service","tenant_id":"tenant","site_ids":["*"],"permissions":["sense.devices.read"]}]}`,
|
||||||
|
`{"version":1,"principals":[{"token_sha256":"` + strings.Repeat("0", 64) + `","subject_id":"x","actor_type":"system","tenant_id":"tenant","site_ids":["*"],"permissions":["sense.devices.read"]}]}`,
|
||||||
|
`{"version":1,"principals":[{"token_sha256":"` + strings.Repeat("0", 64) + `","subject_id":"x","actor_type":"service","tenant_id":"tenant","site_ids":["*"],"permissions":["admin"]}]}`,
|
||||||
|
}
|
||||||
|
for index, document := range tests {
|
||||||
|
path := filepath.Join(t.TempDir(), "auth.json")
|
||||||
|
if err := os.WriteFile(path, []byte(document), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := LoadStaticSHA256(path); err == nil {
|
||||||
|
t.Fatalf("unsafe registry %d was accepted", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// Package config loads and validates the Sense process configuration.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultHTTPAddress = "127.0.0.1:8080"
|
||||||
|
defaultDatabaseDriver = "sqlite"
|
||||||
|
postgresDatabaseDriver = "postgres"
|
||||||
|
defaultDatabaseDSN = "file:data/sense.db"
|
||||||
|
defaultMediaMTXURL = "http://127.0.0.1:9997"
|
||||||
|
defaultReconcilePeriod = 5 * time.Second
|
||||||
|
defaultReconcileLease = 30 * time.Second
|
||||||
|
defaultOperationTimeout = 20 * time.Second
|
||||||
|
defaultProbePeriod = 10 * time.Second
|
||||||
|
defaultOrphanScanPeriod = time.Minute
|
||||||
|
defaultONVIFMode = "disabled"
|
||||||
|
defaultControlAuthMode = "static-sha256"
|
||||||
|
)
|
||||||
|
|
||||||
|
var instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
HTTPAddress string
|
||||||
|
AllowNonLoopback bool
|
||||||
|
DatabaseDriver string
|
||||||
|
DatabaseDSN string
|
||||||
|
MediaMTXURL string
|
||||||
|
ReconcileInterval time.Duration
|
||||||
|
ReconcileLeaseDuration time.Duration
|
||||||
|
ReconcileOperationTimeout time.Duration
|
||||||
|
ProbeInterval time.Duration
|
||||||
|
InstanceID string
|
||||||
|
MetricsEnabled bool
|
||||||
|
OrphanScanEnabled bool
|
||||||
|
OrphanScanInterval time.Duration
|
||||||
|
ONVIFMode string
|
||||||
|
RTSPRewriteHost string
|
||||||
|
RTSPRewritePort int
|
||||||
|
RTSPStripQuery bool
|
||||||
|
ControlAPIEnabled bool
|
||||||
|
ControlAuthMode string
|
||||||
|
ControlAuthFile string
|
||||||
|
ControlCursorKeyFile string
|
||||||
|
ControlAllowInsecureHTTP bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (Config, error) {
|
||||||
|
allow, err := boolEnv("SENSE_ALLOW_NON_LOOPBACK", false)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
reconcilePeriod, err := durationEnv("SENSE_RECONCILE_INTERVAL", defaultReconcilePeriod)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
reconcileLease, err := durationEnv("SENSE_RECONCILE_LEASE_DURATION", defaultReconcileLease)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
operationTimeout, err := durationEnv("SENSE_RECONCILE_OPERATION_TIMEOUT", defaultOperationTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
probePeriod, err := durationEnv("SENSE_PROBE_INTERVAL", defaultProbePeriod)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
rewritePort, err := intEnv("SENSE_ONVIF_RTSP_REWRITE_PORT", 0)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
stripQuery, err := boolEnv("SENSE_ONVIF_RTSP_STRIP_QUERY", false)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
controlEnabled, err := boolEnv("SENSE_CONTROL_API_ENABLED", false)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
controlAllowInsecure, err := boolEnv("SENSE_CONTROL_ALLOW_INSECURE_HTTP", false)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
metricsEnabled, err := boolEnv("SENSE_METRICS_ENABLED", true)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
databaseDriver := stringEnv("SENSE_DB_DRIVER", defaultDatabaseDriver)
|
||||||
|
orphanDefault := strings.EqualFold(strings.TrimSpace(databaseDriver), postgresDatabaseDriver)
|
||||||
|
orphanEnabled, err := boolEnv("SENSE_ORPHAN_SCAN_ENABLED", orphanDefault)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
orphanPeriod, err := durationEnv("SENSE_ORPHAN_SCAN_INTERVAL", defaultOrphanScanPeriod)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
HTTPAddress: stringEnv("SENSE_HTTP_ADDR", defaultHTTPAddress),
|
||||||
|
AllowNonLoopback: allow,
|
||||||
|
DatabaseDriver: databaseDriver,
|
||||||
|
DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN),
|
||||||
|
MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL),
|
||||||
|
ReconcileInterval: reconcilePeriod,
|
||||||
|
ReconcileLeaseDuration: reconcileLease,
|
||||||
|
ReconcileOperationTimeout: operationTimeout,
|
||||||
|
ProbeInterval: probePeriod,
|
||||||
|
InstanceID: stringEnv("SENSE_INSTANCE_ID", ""),
|
||||||
|
MetricsEnabled: metricsEnabled,
|
||||||
|
OrphanScanEnabled: orphanEnabled,
|
||||||
|
OrphanScanInterval: orphanPeriod,
|
||||||
|
ONVIFMode: stringEnv("SENSE_ONVIF_MODE", defaultONVIFMode),
|
||||||
|
RTSPRewriteHost: stringEnv("SENSE_ONVIF_RTSP_REWRITE_HOST", ""),
|
||||||
|
RTSPRewritePort: rewritePort,
|
||||||
|
RTSPStripQuery: stripQuery,
|
||||||
|
ControlAPIEnabled: controlEnabled,
|
||||||
|
ControlAuthMode: stringEnv("SENSE_CONTROL_AUTH_MODE", defaultControlAuthMode),
|
||||||
|
ControlAuthFile: stringEnv("SENSE_CONTROL_AUTH_FILE", ""),
|
||||||
|
ControlCursorKeyFile: stringEnv("SENSE_CONTROL_CURSOR_KEY_FILE", ""),
|
||||||
|
ControlAllowInsecureHTTP: controlAllowInsecure,
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Validate() error {
|
||||||
|
host, _, err := net.SplitHostPort(c.HTTPAddress)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid SENSE_HTTP_ADDR: %w", err)
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
isLoopback := host == "localhost" || (ip != nil && ip.IsLoopback())
|
||||||
|
if !isLoopback && !c.AllowNonLoopback {
|
||||||
|
return fmt.Errorf("non-loopback HTTP bind requires SENSE_ALLOW_NON_LOOPBACK=true")
|
||||||
|
}
|
||||||
|
if c.DatabaseDSN == "" {
|
||||||
|
return fmt.Errorf("SENSE_DB_DSN must not be empty")
|
||||||
|
}
|
||||||
|
databaseDriver := strings.ToLower(strings.TrimSpace(c.DatabaseDriver))
|
||||||
|
if databaseDriver == "" {
|
||||||
|
databaseDriver = defaultDatabaseDriver
|
||||||
|
}
|
||||||
|
if databaseDriver != "sqlite" && databaseDriver != "postgres" {
|
||||||
|
return fmt.Errorf("SENSE_DB_DRIVER must be sqlite or postgres")
|
||||||
|
}
|
||||||
|
if databaseDriver == "postgres" && strings.HasPrefix(c.DatabaseDSN, "file:") {
|
||||||
|
return fmt.Errorf("postgres SENSE_DB_DRIVER requires an explicit PostgreSQL SENSE_DB_DSN")
|
||||||
|
}
|
||||||
|
mediaURL, err := url.Parse(c.MediaMTXURL)
|
||||||
|
if err != nil || mediaURL.Scheme == "" || mediaURL.Host == "" {
|
||||||
|
return fmt.Errorf("invalid SENSE_MEDIAMTX_URL")
|
||||||
|
}
|
||||||
|
if mediaURL.User != nil {
|
||||||
|
return fmt.Errorf("SENSE_MEDIAMTX_URL must not contain credentials")
|
||||||
|
}
|
||||||
|
if c.ReconcileInterval <= 0 || c.ProbeInterval <= 0 {
|
||||||
|
return fmt.Errorf("loop intervals must be positive")
|
||||||
|
}
|
||||||
|
leaseDuration := c.ReconcileLeaseDuration
|
||||||
|
if leaseDuration == 0 {
|
||||||
|
leaseDuration = defaultReconcileLease
|
||||||
|
}
|
||||||
|
operationTimeout := c.ReconcileOperationTimeout
|
||||||
|
if operationTimeout == 0 {
|
||||||
|
operationTimeout = defaultOperationTimeout
|
||||||
|
}
|
||||||
|
if leaseDuration <= 0 || leaseDuration > 5*time.Minute {
|
||||||
|
return fmt.Errorf("SENSE_RECONCILE_LEASE_DURATION must be positive and at most 5m")
|
||||||
|
}
|
||||||
|
if operationTimeout <= 0 || operationTimeout >= leaseDuration {
|
||||||
|
return fmt.Errorf("SENSE_RECONCILE_OPERATION_TIMEOUT must be positive and shorter than the lease")
|
||||||
|
}
|
||||||
|
if c.InstanceID != "" && !instanceIDPattern.MatchString(c.InstanceID) {
|
||||||
|
return fmt.Errorf("invalid SENSE_INSTANCE_ID")
|
||||||
|
}
|
||||||
|
if c.OrphanScanEnabled {
|
||||||
|
if databaseDriver != postgresDatabaseDriver {
|
||||||
|
return fmt.Errorf("orphan scanning requires SENSE_DB_DRIVER=postgres")
|
||||||
|
}
|
||||||
|
orphanInterval := c.OrphanScanInterval
|
||||||
|
if orphanInterval == 0 {
|
||||||
|
orphanInterval = defaultOrphanScanPeriod
|
||||||
|
}
|
||||||
|
if orphanInterval < 10*time.Second {
|
||||||
|
return fmt.Errorf("SENSE_ORPHAN_SCAN_INTERVAL must be at least 10s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.ONVIFMode != "" && c.ONVIFMode != "disabled" && c.ONVIFMode != "standard" {
|
||||||
|
return fmt.Errorf("SENSE_ONVIF_MODE must be disabled or standard")
|
||||||
|
}
|
||||||
|
if c.RTSPRewritePort < 0 || c.RTSPRewritePort > 65535 {
|
||||||
|
return fmt.Errorf("SENSE_ONVIF_RTSP_REWRITE_PORT must be between 0 and 65535")
|
||||||
|
}
|
||||||
|
if c.RTSPRewriteHost != "" {
|
||||||
|
if strings.TrimSpace(c.RTSPRewriteHost) != c.RTSPRewriteHost ||
|
||||||
|
strings.ContainsAny(c.RTSPRewriteHost, "/@") {
|
||||||
|
return fmt.Errorf("invalid SENSE_ONVIF_RTSP_REWRITE_HOST")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.ControlAPIEnabled {
|
||||||
|
if databaseDriver != "postgres" {
|
||||||
|
return fmt.Errorf("Sense Control API requires SENSE_DB_DRIVER=postgres")
|
||||||
|
}
|
||||||
|
if c.ControlAuthMode != defaultControlAuthMode {
|
||||||
|
return fmt.Errorf("SENSE_CONTROL_AUTH_MODE must be static-sha256")
|
||||||
|
}
|
||||||
|
if c.ControlAuthFile == "" || !filepath.IsAbs(c.ControlAuthFile) {
|
||||||
|
return fmt.Errorf("SENSE_CONTROL_AUTH_FILE must be an absolute external path")
|
||||||
|
}
|
||||||
|
if c.ControlCursorKeyFile == "" || !filepath.IsAbs(c.ControlCursorKeyFile) {
|
||||||
|
return fmt.Errorf("SENSE_CONTROL_CURSOR_KEY_FILE must be an absolute external path")
|
||||||
|
}
|
||||||
|
if !isLoopback && !c.ControlAllowInsecureHTTP {
|
||||||
|
return fmt.Errorf("non-loopback Control API requires SENSE_CONTROL_ALLOW_INSECURE_HTTP=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringEnv(name, fallback string) string {
|
||||||
|
if value, ok := os.LookupEnv(name); ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolEnv(name string, fallback bool) (bool, error) {
|
||||||
|
value, ok := os.LookupEnv(name)
|
||||||
|
if !ok {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseBool(value)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("invalid %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
|
||||||
|
value, ok := os.LookupEnv(name)
|
||||||
|
if !ok {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseDuration(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func intEnv(name string, fallback int) (int, error) {
|
||||||
|
value, ok := os.LookupEnv(name)
|
||||||
|
if !ok {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateRejectsNonLoopbackByDefault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := Config{
|
||||||
|
HTTPAddress: "0.0.0.0:8080",
|
||||||
|
DatabaseDSN: "file:test.db",
|
||||||
|
MediaMTXURL: "http://127.0.0.1:9997",
|
||||||
|
ReconcileInterval: 1,
|
||||||
|
ProbeInterval: 1,
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("expected non-loopback bind to be rejected")
|
||||||
|
}
|
||||||
|
cfg.AllowNonLoopback = true
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("explicit non-loopback opt-in failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateReconcileLeaseAndOrphanProductionBoundary(t *testing.T) {
|
||||||
|
base := Config{
|
||||||
|
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres",
|
||||||
|
DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable",
|
||||||
|
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: time.Second,
|
||||||
|
ProbeInterval: time.Second, ReconcileLeaseDuration: 30 * time.Second,
|
||||||
|
ReconcileOperationTimeout: 20 * time.Second, OrphanScanEnabled: true,
|
||||||
|
OrphanScanInterval: time.Minute, InstanceID: "ins_edge-01",
|
||||||
|
}
|
||||||
|
if err := base.Validate(); err != nil {
|
||||||
|
t.Fatalf("valid multi-instance configuration failed: %v", err)
|
||||||
|
}
|
||||||
|
invalidTimeout := base
|
||||||
|
invalidTimeout.ReconcileOperationTimeout = invalidTimeout.ReconcileLeaseDuration
|
||||||
|
if err := invalidTimeout.Validate(); err == nil {
|
||||||
|
t.Fatal("operation timeout equal to the lease was accepted")
|
||||||
|
}
|
||||||
|
invalidInstance := base
|
||||||
|
invalidInstance.InstanceID = "tenant/site"
|
||||||
|
if err := invalidInstance.Validate(); err == nil {
|
||||||
|
t.Fatal("unbounded instance label was accepted")
|
||||||
|
}
|
||||||
|
sqlite := base
|
||||||
|
sqlite.DatabaseDriver, sqlite.DatabaseDSN = "sqlite", "file:test.db"
|
||||||
|
if err := sqlite.Validate(); err == nil {
|
||||||
|
t.Fatal("orphan scanner was accepted on SQLite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsCredentialsInMediaMTXURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := Config{
|
||||||
|
HTTPAddress: "127.0.0.1:8080",
|
||||||
|
DatabaseDSN: "file:test.db",
|
||||||
|
MediaMTXURL: "http://" + "user" + ":" + "redacted" + "@127.0.0.1:9997",
|
||||||
|
ReconcileInterval: 1,
|
||||||
|
ProbeInterval: 1,
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("expected credentials in MediaMTX URL to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateONVIFModeAndRewritePort(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := Config{
|
||||||
|
HTTPAddress: "127.0.0.1:8080",
|
||||||
|
DatabaseDSN: "file:test.db",
|
||||||
|
MediaMTXURL: "http://127.0.0.1:9997",
|
||||||
|
ReconcileInterval: 1,
|
||||||
|
ProbeInterval: 1,
|
||||||
|
ONVIFMode: "standard",
|
||||||
|
RTSPRewriteHost: "127.0.0.1",
|
||||||
|
RTSPRewritePort: 10554,
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("valid ONVIF configuration failed: %v", err)
|
||||||
|
}
|
||||||
|
cfg.ONVIFMode = "vendor"
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("unknown ONVIF mode must be rejected")
|
||||||
|
}
|
||||||
|
cfg.ONVIFMode = "standard"
|
||||||
|
cfg.RTSPRewritePort = 65536
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("invalid RTSP rewrite port must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRTSPStripQueryOptIn(t *testing.T) {
|
||||||
|
t.Setenv("SENSE_ONVIF_RTSP_STRIP_QUERY", "true")
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !cfg.RTSPStripQuery {
|
||||||
|
t.Fatal("explicit RTSP query stripping was not loaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDatabaseDriver(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := Config{
|
||||||
|
HTTPAddress: "127.0.0.1:8080",
|
||||||
|
DatabaseDriver: "postgres",
|
||||||
|
DatabaseDSN: "file:test.db",
|
||||||
|
MediaMTXURL: "http://127.0.0.1:9997",
|
||||||
|
ReconcileInterval: 1,
|
||||||
|
ProbeInterval: 1,
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("postgres driver must reject the SQLite default DSN")
|
||||||
|
}
|
||||||
|
cfg.DatabaseDSN = "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable"
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("valid PostgreSQL selection failed: %v", err)
|
||||||
|
}
|
||||||
|
cfg.DatabaseDriver = "mysql"
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("unknown database driver must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateControlAPIRequiresPostgresAndExternalSecurityFiles(t *testing.T) {
|
||||||
|
cfg := Config{
|
||||||
|
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "sqlite", DatabaseDSN: "file:test.db",
|
||||||
|
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: 1, ProbeInterval: 1,
|
||||||
|
ControlAPIEnabled: true, ControlAuthMode: "static-sha256",
|
||||||
|
ControlAuthFile: filepath.Join(t.TempDir(), "sense-auth.json"),
|
||||||
|
ControlCursorKeyFile: filepath.Join(t.TempDir(), "sense-cursor.key"),
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("Control API was accepted on SQLite")
|
||||||
|
}
|
||||||
|
cfg.DatabaseDriver = "postgres"
|
||||||
|
cfg.DatabaseDSN = "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable"
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("valid Control API configuration failed: %v", err)
|
||||||
|
}
|
||||||
|
cfg.ControlAuthFile = "relative-auth.json"
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("repository-relative authentication file was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateControlAPINonLoopbackNeedsSeparateRiskAcceptance(t *testing.T) {
|
||||||
|
cfg := Config{
|
||||||
|
HTTPAddress: "0.0.0.0:8080", AllowNonLoopback: true,
|
||||||
|
DatabaseDriver: "postgres", DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable",
|
||||||
|
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: 1, ProbeInterval: 1,
|
||||||
|
ControlAPIEnabled: true, ControlAuthMode: "static-sha256",
|
||||||
|
ControlAuthFile: filepath.Join(t.TempDir(), "sense-auth.json"),
|
||||||
|
ControlCursorKeyFile: filepath.Join(t.TempDir(), "sense-cursor.key"),
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("non-loopback plaintext Control API was accepted without explicit risk acceptance")
|
||||||
|
}
|
||||||
|
cfg.ControlAllowInsecureHTTP = true
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("explicit non-loopback Control API risk acceptance failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package controlapi implements the frozen Sense Control API v1.
|
||||||
|
package controlapi
|
||||||
|
|
||||||
|
// The input is the repository-owned public contract frozen by T-008.
|
||||||
|
//go:generate go tool oapi-codegen -config oapi-codegen.yaml ../../../docs/contracts/sense-control-v1.openapi.json
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
package controlapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||||
|
|
||||||
|
func newULID(prefix string, now time.Time) (string, error) {
|
||||||
|
value := make([]byte, 16)
|
||||||
|
milliseconds := uint64(now.UTC().UnixMilli())
|
||||||
|
value[0] = byte(milliseconds >> 40)
|
||||||
|
value[1] = byte(milliseconds >> 32)
|
||||||
|
value[2] = byte(milliseconds >> 24)
|
||||||
|
value[3] = byte(milliseconds >> 16)
|
||||||
|
value[4] = byte(milliseconds >> 8)
|
||||||
|
value[5] = byte(milliseconds)
|
||||||
|
if _, err := rand.Read(value[6:]); err != nil {
|
||||||
|
return "", errors.New("generate identifier randomness")
|
||||||
|
}
|
||||||
|
number := new(big.Int).SetBytes(value)
|
||||||
|
base := big.NewInt(32)
|
||||||
|
remainder := new(big.Int)
|
||||||
|
encoded := make([]byte, 26)
|
||||||
|
for index := len(encoded) - 1; index >= 0; index-- {
|
||||||
|
number.QuoRem(number, base, remainder)
|
||||||
|
encoded[index] = crockford[remainder.Int64()]
|
||||||
|
}
|
||||||
|
return prefix + string(encoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTraceID() (string, error) {
|
||||||
|
value := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(value); err != nil {
|
||||||
|
return "", errors.New("generate trace identifier")
|
||||||
|
}
|
||||||
|
return "trace_" + hex.EncodeToString(value), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package controlapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"yovision/sense/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func publicDevice(value store.ControlDevice) Device {
|
||||||
|
capabilities := make([]Capability, len(value.Capabilities))
|
||||||
|
for index := range value.Capabilities {
|
||||||
|
capabilities[index] = Capability(value.Capabilities[index])
|
||||||
|
}
|
||||||
|
tenantID := LogicalID(value.TenantID)
|
||||||
|
generation, observed := value.Generation, value.ObservedGeneration
|
||||||
|
converged, failureCount := value.Converged, value.FailureCount
|
||||||
|
endpointConfigured, credentialConfigured := value.EndpointConfigured, value.CredentialConfigured
|
||||||
|
createdAt, updatedAt := value.CreatedAt, value.UpdatedAt
|
||||||
|
result := Device{
|
||||||
|
Id: value.ID, TenantId: &tenantID, SiteId: value.SiteID,
|
||||||
|
SerialNumber: value.SerialNumber, Name: value.Name, Modality: Modality(value.Modality),
|
||||||
|
Capabilities: &capabilities, AreaId: value.AreaID,
|
||||||
|
DesiredState: DesiredState(value.DesiredState), ActualState: ActualState(value.ActualState),
|
||||||
|
AdapterStatus: AdapterStatus(value.AdapterStatus),
|
||||||
|
EndpointConfigured: &endpointConfigured, CredentialConfigured: &credentialConfigured,
|
||||||
|
Generation: &generation, ObservedGeneration: &observed, Converged: &converged,
|
||||||
|
FailureCount: &failureCount, NextAttemptAt: value.NextAttemptAt,
|
||||||
|
LastErrorCode: value.LastErrorCode, CreatedAt: &createdAt, UpdatedAt: &updatedAt,
|
||||||
|
}
|
||||||
|
if value.ProjectionVersions.QuotaSourceVersion != nil {
|
||||||
|
version := *value.ProjectionVersions.QuotaSourceVersion
|
||||||
|
result.ProjectionVersions.QuotaSourceVersion = &version
|
||||||
|
}
|
||||||
|
if value.ProjectionVersions.AreaPolicySourceVersion != nil {
|
||||||
|
version := *value.ProjectionVersions.AreaPolicySourceVersion
|
||||||
|
result.ProjectionVersions.AreaPolicySourceVersion = &version
|
||||||
|
}
|
||||||
|
result.ProjectionVersions.SyncedAt = value.ProjectionVersions.SyncedAt
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicQuota(value store.ControlSiteQuota) SiteQuotaStatus {
|
||||||
|
return SiteQuotaStatus{
|
||||||
|
Status: SiteQuotaStatusStatus(value.Status), UsedVideoChannels: value.UsedVideoChannels,
|
||||||
|
MaxVideoChannels: value.MaxVideoChannels,
|
||||||
|
AvailableVideoChannels: value.AvailableVideoChannels,
|
||||||
|
OverLimit: value.OverLimit, SourceVersion: value.SourceVersion, SyncedAt: value.SyncedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicOperation(value store.ControlBatchOperation) BatchOperation {
|
||||||
|
results := make([]BatchItemResult, 0, len(value.Results))
|
||||||
|
for _, item := range value.Results {
|
||||||
|
var code *ErrorCode
|
||||||
|
if item.ErrorCode != nil {
|
||||||
|
converted := ErrorCode(*item.ErrorCode)
|
||||||
|
code = &converted
|
||||||
|
}
|
||||||
|
results = append(results, BatchItemResult{
|
||||||
|
DeviceId: item.DeviceID, Status: BatchItemResultStatus(item.Status),
|
||||||
|
ErrorCode: code, Message: item.Message, Generation: item.Generation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return BatchOperation{
|
||||||
|
Id: value.ID, Status: BatchOperationStatus(value.Status), SubmittedAt: value.SubmittedAt,
|
||||||
|
CompletedAt: value.CompletedAt, Results: results, TraceId: value.TraceID,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package: controlapi
|
||||||
|
output: generated.gen.go
|
||||||
|
generate:
|
||||||
|
models: true
|
||||||
|
std-http-server: true
|
||||||
|
output-options:
|
||||||
|
skip-prune: false
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package controlapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidCursor = errors.New("invalid cursor")
|
||||||
|
|
||||||
|
type CursorPosition struct {
|
||||||
|
CreatedAt time.Time
|
||||||
|
DeviceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type cursorPayload struct {
|
||||||
|
Version int `json:"v"`
|
||||||
|
TenantID string `json:"t"`
|
||||||
|
SiteID string `json:"s"`
|
||||||
|
FilterHash string `json:"f"`
|
||||||
|
CreatedAt string `json:"c"`
|
||||||
|
DeviceID string `json:"d"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CursorCodec struct {
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadCursorCodec(path string) (*CursorCodec, error) {
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("read Control API cursor key")
|
||||||
|
}
|
||||||
|
key, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(contents)))
|
||||||
|
if err != nil || len(key) < 32 {
|
||||||
|
return nil, errors.New("Control API cursor key must be base64url for at least 32 bytes")
|
||||||
|
}
|
||||||
|
return NewCursorCodec(key), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCursorCodec(key []byte) *CursorCodec {
|
||||||
|
copyOfKey := append([]byte(nil), key...)
|
||||||
|
return &CursorCodec{key: copyOfKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CursorCodec) Encode(tenantID, siteID, filterHash string, position CursorPosition) (string, error) {
|
||||||
|
payload, err := json.Marshal(cursorPayload{
|
||||||
|
Version: 1, TenantID: tenantID, SiteID: siteID, FilterHash: filterHash,
|
||||||
|
CreatedAt: position.CreatedAt.UTC().Format(time.RFC3339Nano), DeviceID: position.DeviceID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("encode cursor payload")
|
||||||
|
}
|
||||||
|
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
||||||
|
mac := hmac.New(sha256.New, c.key)
|
||||||
|
_, _ = mac.Write([]byte(encoded))
|
||||||
|
return encoded + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CursorCodec) Decode(value, tenantID, siteID, filterHash string) (CursorPosition, error) {
|
||||||
|
var position CursorPosition
|
||||||
|
parts := strings.Split(value, ".")
|
||||||
|
if len(parts) != 2 || len(value) > 512 {
|
||||||
|
return position, ErrInvalidCursor
|
||||||
|
}
|
||||||
|
provided, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil || len(provided) != sha256.Size {
|
||||||
|
return position, ErrInvalidCursor
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, c.key)
|
||||||
|
_, _ = mac.Write([]byte(parts[0]))
|
||||||
|
if subtle.ConstantTimeCompare(provided, mac.Sum(nil)) != 1 {
|
||||||
|
return position, ErrInvalidCursor
|
||||||
|
}
|
||||||
|
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return position, ErrInvalidCursor
|
||||||
|
}
|
||||||
|
var payload cursorPayload
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(string(payloadBytes)))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&payload); err != nil || payload.Version != 1 ||
|
||||||
|
payload.TenantID != tenantID || payload.SiteID != siteID || payload.FilterHash != filterHash {
|
||||||
|
return position, ErrInvalidCursor
|
||||||
|
}
|
||||||
|
createdAt, err := time.Parse(time.RFC3339Nano, payload.CreatedAt)
|
||||||
|
if err != nil || payload.DeviceID == "" {
|
||||||
|
return position, ErrInvalidCursor
|
||||||
|
}
|
||||||
|
position.CreatedAt = createdAt.UTC()
|
||||||
|
position.DeviceID = payload.DeviceID
|
||||||
|
return position, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterFingerprint(values ...string) string {
|
||||||
|
hash := sha256.New()
|
||||||
|
for _, value := range values {
|
||||||
|
_, _ = hash.Write([]byte{byte(len(value) >> 8), byte(len(value))})
|
||||||
|
_, _ = hash.Write([]byte(value))
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package controlapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCursorIsScopedAndTamperEvident(t *testing.T) {
|
||||||
|
codec := NewCursorCodec(bytes.Repeat([]byte{7}, 32))
|
||||||
|
position := CursorPosition{CreatedAt: time.Date(2026, 8, 7, 1, 2, 3, 4, time.UTC), DeviceID: "dev_1"}
|
||||||
|
value, err := codec.Encode("tenant-a", "site-a", "filters", position)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decoded, err := codec.Decode(value, "tenant-a", "site-a", "filters")
|
||||||
|
if err != nil || decoded.DeviceID != position.DeviceID || !decoded.CreatedAt.Equal(position.CreatedAt) {
|
||||||
|
t.Fatalf("cursor did not round trip: %+v %v", decoded, err)
|
||||||
|
}
|
||||||
|
for name, candidate := range map[string]string{
|
||||||
|
"tenant": "tenant-b", "site": "site-b", "filter": "other",
|
||||||
|
} {
|
||||||
|
tenant, site, filter := "tenant-a", "site-a", "filters"
|
||||||
|
switch name {
|
||||||
|
case "tenant":
|
||||||
|
tenant = candidate
|
||||||
|
case "site":
|
||||||
|
site = candidate
|
||||||
|
case "filter":
|
||||||
|
filter = candidate
|
||||||
|
}
|
||||||
|
if _, err := codec.Decode(value, tenant, site, filter); err == nil {
|
||||||
|
t.Fatalf("cursor was accepted across %s scope", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tampered := value[:len(value)-1] + strings.ToUpper(value[len(value)-1:])
|
||||||
|
if tampered == value {
|
||||||
|
tampered = value[:len(value)-1] + "A"
|
||||||
|
}
|
||||||
|
if _, err := codec.Decode(tampered, "tenant-a", "site-a", "filters"); err == nil {
|
||||||
|
t.Fatal("tampered cursor was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedIdentifiersMatchContractShape(t *testing.T) {
|
||||||
|
value, err := newULID("op_", time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(value) != 29 || !strings.HasPrefix(value, "op_") {
|
||||||
|
t.Fatalf("invalid operation ID %q", value)
|
||||||
|
}
|
||||||
|
trace, err := newTraceID()
|
||||||
|
if err != nil || len(trace) != 38 {
|
||||||
|
t.Fatalf("invalid trace ID %q: %v", trace, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
package controlapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/auth"
|
||||||
|
"yovision/sense/internal/device"
|
||||||
|
"yovision/sense/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type principalContextKey struct{}
|
||||||
|
type traceContextKey struct{}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
store store.ControlRepository
|
||||||
|
authenticator auth.Authenticator
|
||||||
|
cursors *CursorCodec
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPHandler(
|
||||||
|
repository store.ControlRepository, authenticator auth.Authenticator, cursors *CursorCodec,
|
||||||
|
) http.Handler {
|
||||||
|
server := &Server{
|
||||||
|
store: repository, authenticator: authenticator, cursors: cursors, now: time.Now,
|
||||||
|
}
|
||||||
|
generated := HandlerWithOptions(server, StdHTTPServerOptions{ErrorHandlerFunc: server.bindError})
|
||||||
|
return server.authenticate(generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authenticate(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
traceID, err := newTraceID()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(writer, "service unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(request.Context(), traceContextKey{}, traceID)
|
||||||
|
request = request.WithContext(ctx)
|
||||||
|
values := request.Header.Values("Authorization")
|
||||||
|
if len(values) != 1 {
|
||||||
|
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
|
||||||
|
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.Fields(values[0])
|
||||||
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||||
|
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
|
||||||
|
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, err := s.authenticator.Authenticate(request.Context(), parts[1])
|
||||||
|
if err != nil {
|
||||||
|
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
|
||||||
|
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx = context.WithValue(request.Context(), principalContextKey{}, principal)
|
||||||
|
next.ServeHTTP(writer, request.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func principalFromContext(ctx context.Context) auth.Principal {
|
||||||
|
value, _ := ctx.Value(principalContextKey{}).(auth.Principal)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func traceFromContext(ctx context.Context) string {
|
||||||
|
value, _ := ctx.Value(traceContextKey{}).(string)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) bindError(writer http.ResponseWriter, request *http.Request, err error) {
|
||||||
|
var required *RequiredHeaderError
|
||||||
|
if errors.As(err, &required) && required.ParamName == "If-Match" {
|
||||||
|
s.writeProblem(writer, request, http.StatusPreconditionRequired,
|
||||||
|
ErrorCodePreconditionRequired, "If-Match is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "request parameters are invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) requireSite(
|
||||||
|
writer http.ResponseWriter, request *http.Request, siteID, permission string,
|
||||||
|
) (auth.Principal, bool) {
|
||||||
|
principal := principalFromContext(request.Context())
|
||||||
|
if !validLogicalID(siteID) {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "site_id is invalid")
|
||||||
|
return auth.Principal{}, false
|
||||||
|
}
|
||||||
|
if !principal.AllowsSite(siteID) {
|
||||||
|
s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found")
|
||||||
|
return auth.Principal{}, false
|
||||||
|
}
|
||||||
|
if !principal.Has(permission) {
|
||||||
|
s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required")
|
||||||
|
return auth.Principal{}, false
|
||||||
|
}
|
||||||
|
return principal, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeProblem(
|
||||||
|
writer http.ResponseWriter, request *http.Request, status int, code ErrorCode, message string,
|
||||||
|
) {
|
||||||
|
traceID := traceFromContext(request.Context())
|
||||||
|
writer.Header().Set("Content-Type", "application/problem+json")
|
||||||
|
writer.Header().Set("Cache-Control", "no-store")
|
||||||
|
writer.Header().Set("X-Trace-ID", traceID)
|
||||||
|
writer.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(writer).Encode(Problem{
|
||||||
|
Type: "/problems/" + string(code), Title: http.StatusText(status), Status: status,
|
||||||
|
Code: code, Message: message, TraceId: traceID, FieldErrors: []FieldError{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(writer http.ResponseWriter, status int, traceID string, value any) {
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
writer.Header().Set("Cache-Control", "no-store")
|
||||||
|
writer.Header().Set("X-Trace-ID", traceID)
|
||||||
|
writer.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(writer).Encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ListDevices(
|
||||||
|
writer http.ResponseWriter, request *http.Request, siteID SiteID, params ListDevicesParams,
|
||||||
|
) {
|
||||||
|
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 50
|
||||||
|
if params.Limit != nil {
|
||||||
|
limit = *params.Limit
|
||||||
|
}
|
||||||
|
if limit < 1 || limit > 100 ||
|
||||||
|
(params.Modality != nil && !Modality(*params.Modality).Valid()) ||
|
||||||
|
(params.Capability != nil && !Capability(*params.Capability).Valid()) ||
|
||||||
|
(params.DesiredState != nil && !DesiredState(*params.DesiredState).Valid()) ||
|
||||||
|
(params.ActualState != nil && !ActualState(*params.ActualState).Valid()) {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "list filters are invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filterHash := filterFingerprint(
|
||||||
|
optionalString(params.Modality), optionalString(params.Capability),
|
||||||
|
optionalString(params.DesiredState), optionalString(params.ActualState),
|
||||||
|
)
|
||||||
|
filter := store.ControlListFilter{Limit: limit}
|
||||||
|
if params.Modality != nil {
|
||||||
|
value := device.Modality(*params.Modality)
|
||||||
|
filter.Modality = &value
|
||||||
|
}
|
||||||
|
if params.Capability != nil {
|
||||||
|
value := device.Capability(*params.Capability)
|
||||||
|
filter.Capability = &value
|
||||||
|
}
|
||||||
|
if params.DesiredState != nil {
|
||||||
|
value := device.DesiredState(*params.DesiredState)
|
||||||
|
filter.DesiredState = &value
|
||||||
|
}
|
||||||
|
if params.ActualState != nil {
|
||||||
|
value := device.ActualState(*params.ActualState)
|
||||||
|
filter.ActualState = &value
|
||||||
|
}
|
||||||
|
if params.Cursor != nil {
|
||||||
|
position, err := s.cursors.Decode(*params.Cursor, principal.TenantID, siteID, filterHash)
|
||||||
|
if err != nil {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "cursor is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter.AfterCreated = &position.CreatedAt
|
||||||
|
filter.AfterDeviceID = position.DeviceID
|
||||||
|
}
|
||||||
|
page, err := s.store.ListControlDevices(request.Context(), principal.TenantID, siteID, filter)
|
||||||
|
if err != nil {
|
||||||
|
s.writeStoreError(writer, request, err, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]Device, 0, len(page.Items))
|
||||||
|
for _, value := range page.Items {
|
||||||
|
items = append(items, publicDevice(value))
|
||||||
|
}
|
||||||
|
var nextCursor *string
|
||||||
|
if page.HasMore && len(page.Items) > 0 {
|
||||||
|
last := page.Items[len(page.Items)-1]
|
||||||
|
value, err := s.cursors.Encode(principal.TenantID, siteID, filterHash,
|
||||||
|
CursorPosition{CreatedAt: last.CreatedAt, DeviceID: last.ID})
|
||||||
|
if err != nil {
|
||||||
|
s.writeProblem(writer, request, http.StatusInternalServerError, ErrorCodeInternalError, "response could not be created")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextCursor = &value
|
||||||
|
}
|
||||||
|
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), DevicePage{
|
||||||
|
Items: items, Page: PageInfo{Limit: limit, HasMore: page.HasMore, NextCursor: nextCursor},
|
||||||
|
Quota: publicQuota(page.Quota),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalString[T ~string](value *T) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(*value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) CreateDevice(
|
||||||
|
writer http.ResponseWriter, request *http.Request, siteID SiteID, params CreateDeviceParams,
|
||||||
|
) {
|
||||||
|
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contents, err := readRequestBody(request, "application/json")
|
||||||
|
if err != nil {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := rejectTopLevelNulls(contents); err != nil {
|
||||||
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body DeviceCreate
|
||||||
|
if err := decodeStrictJSON(contents, &body); err != nil {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if code, message := validateDeviceCreate(body); code != "" {
|
||||||
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
desired := Disabled
|
||||||
|
if body.DesiredState != nil {
|
||||||
|
desired = *body.DesiredState
|
||||||
|
}
|
||||||
|
body.DesiredState = &desired
|
||||||
|
canonical, _ := json.Marshal(body)
|
||||||
|
requestHash := sha256.Sum256(canonical)
|
||||||
|
now := s.now().UTC()
|
||||||
|
deviceID, err := newULID("dev_", now)
|
||||||
|
if err != nil {
|
||||||
|
s.writeProblem(writer, request, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "identifier could not be generated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
capabilities := make([]device.Capability, len(body.Capabilities))
|
||||||
|
for index := range body.Capabilities {
|
||||||
|
capabilities[index] = device.Capability(body.Capabilities[index])
|
||||||
|
}
|
||||||
|
value := device.Device{
|
||||||
|
ID: deviceID, TenantID: principal.TenantID, SiteID: siteID, AreaID: body.AreaId,
|
||||||
|
SerialNumber: body.SerialNumber, Name: body.Name, Modality: device.Modality(body.Modality),
|
||||||
|
Capabilities: capabilities, DesiredState: device.DesiredState(desired),
|
||||||
|
ActualState: device.ActualPending, PathName: "devices/" + deviceID,
|
||||||
|
Generation: 1, ResourceVersion: 1,
|
||||||
|
}
|
||||||
|
if body.EndpointRef != nil {
|
||||||
|
value.EndpointRef = *body.EndpointRef
|
||||||
|
}
|
||||||
|
if body.CredentialRef != nil {
|
||||||
|
value.CredentialRef = *body.CredentialRef
|
||||||
|
}
|
||||||
|
if body.ProfileToken != nil {
|
||||||
|
value.ProfileToken = *body.ProfileToken
|
||||||
|
}
|
||||||
|
traceID := traceFromContext(request.Context())
|
||||||
|
ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID))
|
||||||
|
result, err := s.store.CreateControlDevice(ctx, store.ControlCreateRequest{
|
||||||
|
Scope: store.IdempotencyScope{
|
||||||
|
PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID,
|
||||||
|
Operation: "createDevice", Key: params.IdempotencyKey,
|
||||||
|
RequestHash: requestHash, TraceID: traceID,
|
||||||
|
},
|
||||||
|
Device: value,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.writeStoreError(writer, request, err, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.Header().Set("ETag", result.ETag)
|
||||||
|
writer.Header().Set("Location", result.Location)
|
||||||
|
writeJSON(writer, http.StatusCreated, result.TraceID, publicDevice(result.Device))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDeviceCreate(body DeviceCreate) (string, string) {
|
||||||
|
if !validLogicalID(body.AreaId) || !validLength(body.SerialNumber, 1, 128) ||
|
||||||
|
!validLength(body.Name, 1, 200) || strings.TrimSpace(body.SerialNumber) == "" ||
|
||||||
|
strings.TrimSpace(body.Name) == "" || !body.Modality.Valid() ||
|
||||||
|
len(body.Capabilities) < 1 || len(body.Capabilities) > 16 {
|
||||||
|
return "invalid_request", "device fields are invalid"
|
||||||
|
}
|
||||||
|
seen := make(map[Capability]struct{}, len(body.Capabilities))
|
||||||
|
hasVideo := false
|
||||||
|
for _, capability := range body.Capabilities {
|
||||||
|
if !capability.Valid() {
|
||||||
|
return "invalid_request", "device capability is invalid"
|
||||||
|
}
|
||||||
|
if _, exists := seen[capability]; exists {
|
||||||
|
return "invalid_request", "device capabilities contain a duplicate"
|
||||||
|
}
|
||||||
|
seen[capability] = struct{}{}
|
||||||
|
hasVideo = hasVideo || capability == VideoCapture
|
||||||
|
}
|
||||||
|
if body.Modality == Video && !hasVideo {
|
||||||
|
return "invalid_request", "video modality requires video_capture"
|
||||||
|
}
|
||||||
|
if hasVideo && (body.EndpointRef == nil || body.CredentialRef == nil) {
|
||||||
|
return "adapter_not_ready", "video capture requires endpoint and credential references"
|
||||||
|
}
|
||||||
|
if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) {
|
||||||
|
return "endpoint_credentials_forbidden", "endpoint reference is invalid"
|
||||||
|
}
|
||||||
|
if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") {
|
||||||
|
return "endpoint_credentials_forbidden", "credential reference is invalid"
|
||||||
|
}
|
||||||
|
if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) {
|
||||||
|
return "invalid_request", "profile token is invalid"
|
||||||
|
}
|
||||||
|
if body.DesiredState != nil && !body.DesiredState.Valid() {
|
||||||
|
return "invalid_request", "desired_state is invalid"
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditContext(principal auth.Principal, reason, traceID string) store.AuditContext {
|
||||||
|
actorType := store.AuditActorService
|
||||||
|
if principal.ActorType == "user" {
|
||||||
|
actorType = store.AuditActorUser
|
||||||
|
}
|
||||||
|
return store.AuditContext{
|
||||||
|
ActorType: actorType, ActorID: principal.SubjectID, Reason: reason, TraceID: traceID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetDevice(
|
||||||
|
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
|
||||||
|
) {
|
||||||
|
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validLogicalID(deviceID) {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value, err := s.store.GetControlDevice(request.Context(), principal.TenantID, siteID, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeStoreError(writer, request, err, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.Header().Set("ETag", store.DeviceETag(value.ID, value.ResourceVersion))
|
||||||
|
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicDevice(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) UpdateDevice(
|
||||||
|
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
|
||||||
|
params UpdateDeviceParams,
|
||||||
|
) {
|
||||||
|
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contents, err := readRequestBody(request, "application/merge-patch+json")
|
||||||
|
if err != nil {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(contents, &fields); err != nil || len(fields) == 0 {
|
||||||
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch must contain a field")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, value := range fields {
|
||||||
|
if string(value) == "null" {
|
||||||
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch fields cannot be null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var body DevicePatch
|
||||||
|
if err := decodeStrictJSON(contents, &body); err != nil {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if code, message := validateDevicePatch(body); code != "" {
|
||||||
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
patch := store.ControlPatch{
|
||||||
|
Name: body.Name, EndpointRef: body.EndpointRef,
|
||||||
|
CredentialRef: body.CredentialRef, ProfileToken: body.ProfileToken,
|
||||||
|
}
|
||||||
|
if body.AreaId != nil {
|
||||||
|
value := string(*body.AreaId)
|
||||||
|
patch.AreaID = &value
|
||||||
|
}
|
||||||
|
traceID := traceFromContext(request.Context())
|
||||||
|
ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID))
|
||||||
|
result, err := s.store.PatchControlDevice(
|
||||||
|
ctx, principal.TenantID, siteID, deviceID, params.IfMatch, patch,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
s.writeStoreError(writer, request, err, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.Header().Set("ETag", result.ETag)
|
||||||
|
writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{
|
||||||
|
Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDevicePatch(body DevicePatch) (string, string) {
|
||||||
|
if body.Name != nil && (!validLength(*body.Name, 1, 200) || strings.TrimSpace(*body.Name) == "") {
|
||||||
|
return "invalid_request", "name is invalid"
|
||||||
|
}
|
||||||
|
if body.AreaId != nil && !validLogicalID(*body.AreaId) {
|
||||||
|
return "invalid_request", "area_id is invalid"
|
||||||
|
}
|
||||||
|
if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) {
|
||||||
|
return "endpoint_credentials_forbidden", "endpoint reference is invalid"
|
||||||
|
}
|
||||||
|
if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") {
|
||||||
|
return "endpoint_credentials_forbidden", "credential reference is invalid"
|
||||||
|
}
|
||||||
|
if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) {
|
||||||
|
return "invalid_request", "profile token is invalid"
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) SetDeviceDesiredState(
|
||||||
|
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
|
||||||
|
params SetDeviceDesiredStateParams,
|
||||||
|
) {
|
||||||
|
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contents, err := readRequestBody(request, "application/json")
|
||||||
|
if err != nil {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body DesiredStateChange
|
||||||
|
if err := decodeStrictJSON(contents, &body); err != nil {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !body.DesiredState.Valid() || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" {
|
||||||
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "desired_state or reason is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
traceID := traceFromContext(request.Context())
|
||||||
|
ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID))
|
||||||
|
result, err := s.store.SetControlDesiredState(
|
||||||
|
ctx, principal.TenantID, siteID, deviceID, params.IfMatch,
|
||||||
|
device.DesiredState(body.DesiredState),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
s.writeStoreError(writer, request, err, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.Header().Set("ETag", result.ETag)
|
||||||
|
writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{
|
||||||
|
Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) BatchSetDeviceDesiredState(
|
||||||
|
writer http.ResponseWriter, request *http.Request, siteID SiteID,
|
||||||
|
params BatchSetDeviceDesiredStateParams,
|
||||||
|
) {
|
||||||
|
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contents, err := readRequestBody(request, "application/json")
|
||||||
|
if err != nil {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body BatchDesiredStateRequest
|
||||||
|
if err := decodeStrictJSON(contents, &body); err != nil {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Items) > 128 {
|
||||||
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeBatchTooLarge, "batch contains more than 128 items")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Items) == 0 || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" {
|
||||||
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch items or reason is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]store.ControlBatchItem, 0, len(body.Items))
|
||||||
|
for _, item := range body.Items {
|
||||||
|
if !validLogicalID(item.DeviceId) || !validStrongETag(item.Etag) || !item.DesiredState.Valid() {
|
||||||
|
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch item is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items = append(items, store.ControlBatchItem{
|
||||||
|
DeviceID: item.DeviceId, ETag: item.Etag,
|
||||||
|
DesiredState: device.DesiredState(item.DesiredState),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
canonical, _ := json.Marshal(body)
|
||||||
|
requestHash := sha256.Sum256(canonical)
|
||||||
|
traceID := traceFromContext(request.Context())
|
||||||
|
ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID))
|
||||||
|
operation, err := s.store.BatchSetControlDesiredState(ctx, store.ControlBatchRequest{
|
||||||
|
Scope: store.IdempotencyScope{
|
||||||
|
PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID,
|
||||||
|
Operation: "batchSetDeviceDesiredState", Key: params.IdempotencyKey,
|
||||||
|
RequestHash: requestHash, TraceID: traceID,
|
||||||
|
},
|
||||||
|
Reason: body.Reason, Items: items,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.writeStoreError(writer, request, err, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.Header().Set("Location", "/api/v1/operations/"+operation.ID)
|
||||||
|
writeJSON(writer, http.StatusAccepted, operation.TraceID, publicOperation(operation))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetOperation(
|
||||||
|
writer http.ResponseWriter, request *http.Request, operationID OperationID,
|
||||||
|
) {
|
||||||
|
principal := principalFromContext(request.Context())
|
||||||
|
if !principal.Has(auth.PermissionDevicesRead) {
|
||||||
|
s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !operationIDRegex.MatchString(operationID) {
|
||||||
|
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "operation_id is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
operation, err := s.store.GetControlOperation(request.Context(), principal.TenantID, operationID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeStoreError(writer, request, err, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !principal.AllowsSite(operation.SiteID) {
|
||||||
|
s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicOperation(operation))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeStoreError(
|
||||||
|
writer http.ResponseWriter, request *http.Request, err error, mutation bool,
|
||||||
|
) {
|
||||||
|
status, code, message := http.StatusInternalServerError, ErrorCodeInternalError, "request could not be completed"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, store.ErrNotFound):
|
||||||
|
status, code, message = http.StatusNotFound, ErrorCodeNotFound, "resource was not found"
|
||||||
|
case errors.Is(err, store.ErrETagMismatch):
|
||||||
|
status, code, message = http.StatusPreconditionFailed, ErrorCodeEtagMismatch, "device ETag does not match"
|
||||||
|
case errors.Is(err, store.ErrIdempotencyConflict):
|
||||||
|
status, code, message = http.StatusConflict, ErrorCodeIdempotencyConflict, "Idempotency-Key was used with another request"
|
||||||
|
case errors.Is(err, store.ErrDuplicateSerialNumber):
|
||||||
|
status, code, message = http.StatusConflict, ErrorCodeDuplicateSerialNumber, "serial_number already exists in this site"
|
||||||
|
case errors.Is(err, store.ErrAreaPolicyDenied):
|
||||||
|
status, code, message = http.StatusUnprocessableEntity, ErrorCodeAreaPolicyDenied, "Area policy denies this change"
|
||||||
|
case errors.Is(err, store.ErrAreaPolicyUnavailable), errors.Is(err, store.ErrAreaPolicyInvalid):
|
||||||
|
status, code, message = http.StatusServiceUnavailable, ErrorCodeAreaPolicyUnavailable, "Area policy is unavailable"
|
||||||
|
case errors.Is(err, store.ErrQuotaProjectionUnavailable):
|
||||||
|
status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionUnavailable, "Site quota is unavailable"
|
||||||
|
case errors.Is(err, store.ErrQuotaProjectionInvalid):
|
||||||
|
status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionInvalid, "Site quota is invalid"
|
||||||
|
default:
|
||||||
|
var quotaError *device.QuotaExceededError
|
||||||
|
if errors.As(err, "aError) {
|
||||||
|
status, code, message = http.StatusConflict, ErrorCodeQuotaExceeded, "Site video channel quota is exceeded"
|
||||||
|
} else if mutation {
|
||||||
|
status, code, message = http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "device change could not be accepted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.writeProblem(writer, request, status, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ServerInterface = (*Server)(nil)
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package controlapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/auth"
|
||||||
|
"yovision/sense/internal/device"
|
||||||
|
"yovision/sense/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testOperationID = "op_01K20Y8Q3E0000000000000000"
|
||||||
|
|
||||||
|
type fixedAuthenticator struct {
|
||||||
|
principal auth.Principal
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a fixedAuthenticator) Authenticate(context.Context, string) (auth.Principal, error) {
|
||||||
|
return a.principal, a.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeControlStore struct {
|
||||||
|
device store.ControlDevice
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeControlStore) ListControlDevices(context.Context, string, string, store.ControlListFilter) (store.ControlDevicePage, error) {
|
||||||
|
return store.ControlDevicePage{
|
||||||
|
Items: []store.ControlDevice{f.device}, Quota: store.ControlSiteQuota{Status: "current"},
|
||||||
|
}, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeControlStore) CreateControlDevice(_ context.Context, request store.ControlCreateRequest) (store.ControlCreateResult, error) {
|
||||||
|
return store.ControlCreateResult{
|
||||||
|
Device: f.device, AcceptedAt: time.Now(), TraceID: request.Scope.TraceID,
|
||||||
|
ETag: store.DeviceETag(f.device.ID, f.device.ResourceVersion),
|
||||||
|
Location: "/api/v1/sites/site-a/devices/" + f.device.ID,
|
||||||
|
}, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeControlStore) GetControlDevice(context.Context, string, string, string) (store.ControlDevice, error) {
|
||||||
|
return f.device, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeControlStore) PatchControlDevice(_ context.Context, _, _, _, _ string, _ store.ControlPatch) (store.ControlMutationResult, error) {
|
||||||
|
return f.mutation(), f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeControlStore) SetControlDesiredState(_ context.Context, _, _, _, _ string, _ device.DesiredState) (store.ControlMutationResult, error) {
|
||||||
|
return f.mutation(), f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeControlStore) mutation() store.ControlMutationResult {
|
||||||
|
return store.ControlMutationResult{
|
||||||
|
Device: f.device, AcceptedAt: time.Now(), TraceID: "trace_0123456789abcdef0123456789abcdef",
|
||||||
|
ETag: store.DeviceETag(f.device.ID, f.device.ResourceVersion),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeControlStore) BatchSetControlDesiredState(_ context.Context, request store.ControlBatchRequest) (store.ControlBatchOperation, error) {
|
||||||
|
completed := time.Now().UTC()
|
||||||
|
generation := f.device.Generation
|
||||||
|
return store.ControlBatchOperation{
|
||||||
|
ID: testOperationID, TenantID: request.Scope.TenantID, SiteID: request.Scope.SiteID,
|
||||||
|
Status: "succeeded", SubmittedAt: completed, CompletedAt: &completed,
|
||||||
|
TraceID: request.Scope.TraceID,
|
||||||
|
Results: []store.ControlBatchItemResult{{
|
||||||
|
DeviceID: f.device.ID, Status: "succeeded", Generation: &generation,
|
||||||
|
}},
|
||||||
|
}, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeControlStore) GetControlOperation(context.Context, string, string) (store.ControlBatchOperation, error) {
|
||||||
|
completed := time.Now().UTC()
|
||||||
|
return store.ControlBatchOperation{
|
||||||
|
ID: testOperationID, TenantID: "tenant-a", SiteID: "site-a", Status: "succeeded",
|
||||||
|
SubmittedAt: completed, CompletedAt: &completed, Results: []store.ControlBatchItemResult{},
|
||||||
|
TraceID: "trace_0123456789abcdef0123456789abcdef",
|
||||||
|
}, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHTTPHandler(repository *fakeControlStore, permissions ...string) http.Handler {
|
||||||
|
grants := make(map[string]struct{}, len(permissions))
|
||||||
|
for _, permission := range permissions {
|
||||||
|
grants[permission] = struct{}{}
|
||||||
|
}
|
||||||
|
principal := auth.Principal{
|
||||||
|
SubjectID: "operator-1", ActorType: "user", TenantID: "tenant-a",
|
||||||
|
SiteIDs: []string{"site-a"}, Permissions: grants,
|
||||||
|
}
|
||||||
|
return NewHTTPHandler(repository, fixedAuthenticator{principal: principal}, NewCursorCodec(bytes.Repeat([]byte{4}, 32)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testControlDevice() store.ControlDevice {
|
||||||
|
quotaVersion, areaVersion := int64(1), int64(2)
|
||||||
|
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||||
|
return store.ControlDevice{
|
||||||
|
ID: "dev_01K20Y8Q3E0000000000000000", TenantID: "tenant-a", SiteID: "site-a",
|
||||||
|
SerialNumber: "serial-1", Name: "Camera", Modality: device.ModalityVideo,
|
||||||
|
Capabilities: []device.Capability{device.CapabilityVideoCapture}, AreaID: "area-a",
|
||||||
|
DesiredState: device.DesiredDisabled, ActualState: device.ActualPending,
|
||||||
|
AdapterStatus: "pending", EndpointConfigured: true, CredentialConfigured: true,
|
||||||
|
Generation: 1, ResourceVersion: 1, FailureCount: 0,
|
||||||
|
ProjectionVersions: store.ControlProjectionVersions{
|
||||||
|
QuotaSourceVersion: "aVersion, AreaPolicySourceVersion: &areaVersion, SyncedAt: &now,
|
||||||
|
},
|
||||||
|
CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func performRequest(handler http.Handler, method, path, contentType, body string) *httptest.ResponseRecorder {
|
||||||
|
request := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||||
|
if contentType != "" {
|
||||||
|
request.Header.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
return recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllFrozenControlRoutesReturnContractShapes(t *testing.T) {
|
||||||
|
value := testControlDevice()
|
||||||
|
repository := &fakeControlStore{device: value}
|
||||||
|
handler := testHTTPHandler(repository, auth.PermissionDevicesRead, auth.PermissionDevicesWrite)
|
||||||
|
etag := store.DeviceETag(value.ID, value.ResourceVersion)
|
||||||
|
|
||||||
|
list := performRequest(handler, http.MethodGet, "/api/v1/sites/site-a/devices", "", "")
|
||||||
|
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"quota"`) {
|
||||||
|
t.Fatalf("list route failed: %d %s", list.Code, list.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
createBody := `{"serial_number":"serial-1","name":"Camera","modality":"video",` +
|
||||||
|
`"capabilities":["video_capture"],"area_id":"area-a",` +
|
||||||
|
`"endpoint_ref":"onvif://camera","credential_ref":"env://CAMERA"}`
|
||||||
|
create := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices", strings.NewReader(createBody))
|
||||||
|
create.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||||
|
create.Header.Set("Content-Type", "application/json")
|
||||||
|
create.Header.Set("Idempotency-Key", "create-request-0001")
|
||||||
|
createResult := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(createResult, create)
|
||||||
|
if createResult.Code != http.StatusCreated || createResult.Header().Get("ETag") == "" ||
|
||||||
|
strings.Contains(createResult.Body.String(), "endpoint_ref") || strings.Contains(createResult.Body.String(), "credential_ref") {
|
||||||
|
t.Fatalf("create route failed or leaked write-only data: %d %s", createResult.Code, createResult.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
get := performRequest(handler, http.MethodGet, "/api/v1/sites/site-a/devices/"+value.ID, "", "")
|
||||||
|
if get.Code != http.StatusOK || get.Header().Get("ETag") != etag {
|
||||||
|
t.Fatalf("get route failed: %d %s", get.Code, get.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID, strings.NewReader(`{"name":"Updated"}`))
|
||||||
|
patch.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||||
|
patch.Header.Set("Content-Type", "application/merge-patch+json")
|
||||||
|
patch.Header.Set("If-Match", etag)
|
||||||
|
patchResult := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(patchResult, patch)
|
||||||
|
if patchResult.Code != http.StatusAccepted || !strings.Contains(patchResult.Body.String(), `"accepted_at"`) {
|
||||||
|
t.Fatalf("patch route failed: %d %s", patchResult.Code, patchResult.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
desired := httptest.NewRequest(http.MethodPut, "/api/v1/sites/site-a/devices/"+value.ID+"/desired-state", strings.NewReader(`{"desired_state":"enabled","reason":"test"}`))
|
||||||
|
desired.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||||
|
desired.Header.Set("Content-Type", "application/json")
|
||||||
|
desired.Header.Set("If-Match", etag)
|
||||||
|
desiredResult := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(desiredResult, desired)
|
||||||
|
if desiredResult.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("desired-state route failed: %d %s", desiredResult.Code, desiredResult.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
batchBody := `{"items":[{"device_id":"` + value.ID + `","etag":"` + strings.ReplaceAll(etag, `"`, `\"`) +
|
||||||
|
`","desired_state":"enabled"}],"reason":"test"}`
|
||||||
|
batch := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices:batchDesiredState", strings.NewReader(batchBody))
|
||||||
|
batch.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||||
|
batch.Header.Set("Content-Type", "application/json")
|
||||||
|
batch.Header.Set("Idempotency-Key", "batch-request-0001")
|
||||||
|
batchResult := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(batchResult, batch)
|
||||||
|
if batchResult.Code != http.StatusAccepted || batchResult.Header().Get("Location") == "" {
|
||||||
|
t.Fatalf("batch route failed: %d %s", batchResult.Code, batchResult.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
operation := performRequest(handler, http.MethodGet, "/api/v1/operations/"+testOperationID, "", "")
|
||||||
|
if operation.Code != http.StatusOK || !strings.Contains(operation.Body.String(), `"results"`) {
|
||||||
|
t.Fatalf("operation route failed: %d %s", operation.Code, operation.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticationScopeAndPreconditionsUseStableProblems(t *testing.T) {
|
||||||
|
value := testControlDevice()
|
||||||
|
handler := testHTTPHandler(&fakeControlStore{device: value}, auth.PermissionDevicesRead)
|
||||||
|
|
||||||
|
unauthenticated := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(unauthenticated, httptest.NewRequest(http.MethodGet, "/api/v1/sites/site-a/devices", nil))
|
||||||
|
if unauthenticated.Code != http.StatusUnauthorized || unauthenticated.Header().Get("WWW-Authenticate") == "" {
|
||||||
|
t.Fatalf("missing auth did not return 401: %d", unauthenticated.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hidden := performRequest(handler, http.MethodGet, "/api/v1/sites/site-b/devices", "", "")
|
||||||
|
if hidden.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("out-of-scope site was not hidden: %d", hidden.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
missingPrecondition := performRequest(handler, http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID,
|
||||||
|
"application/merge-patch+json", `{"name":"Updated"}`)
|
||||||
|
if missingPrecondition.Code != http.StatusPreconditionRequired {
|
||||||
|
t.Fatalf("missing If-Match did not return 428: %d %s", missingPrecondition.Code, missingPrecondition.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var problem Problem
|
||||||
|
if err := json.Unmarshal(missingPrecondition.Body.Bytes(), &problem); err != nil || problem.Code != ErrorCodePreconditionRequired || problem.TraceId == "" {
|
||||||
|
t.Fatalf("invalid Problem response: %+v %v", problem, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrictJSONAndStoreConflictsAreMapped(t *testing.T) {
|
||||||
|
value := testControlDevice()
|
||||||
|
handler := testHTTPHandler(&fakeControlStore{device: value}, auth.PermissionDevicesWrite)
|
||||||
|
etag := store.DeviceETag(value.ID, value.ResourceVersion)
|
||||||
|
duplicate := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID,
|
||||||
|
strings.NewReader(`{"name":"one","name":"two"}`))
|
||||||
|
duplicate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||||
|
duplicate.Header.Set("Content-Type", "application/merge-patch+json")
|
||||||
|
duplicate.Header.Set("If-Match", etag)
|
||||||
|
result := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(result, duplicate)
|
||||||
|
if result.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("duplicate JSON property was accepted: %d %s", result.Code, result.Body.String())
|
||||||
|
}
|
||||||
|
nullCreate := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices", strings.NewReader(
|
||||||
|
`{"serial_number":"serial","name":"Camera","modality":"radar",`+
|
||||||
|
`"capabilities":["telemetry"],"area_id":"area-a","profile_token":null}`))
|
||||||
|
nullCreate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||||
|
nullCreate.Header.Set("Content-Type", "application/json")
|
||||||
|
nullCreate.Header.Set("Idempotency-Key", "null-create-0001")
|
||||||
|
nullResult := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(nullResult, nullCreate)
|
||||||
|
if nullResult.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("explicit null was accepted: %d %s", nullResult.Code, nullResult.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
conflictHandler := testHTTPHandler(&fakeControlStore{device: value, err: store.ErrETagMismatch}, auth.PermissionDevicesWrite)
|
||||||
|
request := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID, strings.NewReader(`{"name":"two"}`))
|
||||||
|
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||||
|
request.Header.Set("Content-Type", "application/merge-patch+json")
|
||||||
|
request.Header.Set("If-Match", etag)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
conflictHandler.ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusPreconditionFailed || !strings.Contains(recorder.Body.String(), "etag_mismatch") {
|
||||||
|
t.Fatalf("ETag mismatch was not mapped: %d %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectionVersionsAlwaysEmitRequiredNullableKeys(t *testing.T) {
|
||||||
|
encoded, err := json.Marshal(ProjectionVersions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"quota_source_version", "area_policy_source_version", "synced_at"} {
|
||||||
|
if !strings.Contains(string(encoded), `"`+key+`":null`) {
|
||||||
|
t.Fatalf("required nullable key %s was omitted: %s", key, encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package controlapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maximumRequestBody = 1 << 20
|
||||||
|
|
||||||
|
var (
|
||||||
|
logicalIDRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`)
|
||||||
|
idempotencyKeyRegex = regexp.MustCompile(`^[A-Za-z0-9._:-]{16,128}$`)
|
||||||
|
operationIDRegex = regexp.MustCompile(`^op_[0-9A-HJKMNP-TV-Z]{26}$`)
|
||||||
|
strongETagRegex = regexp.MustCompile(`^"[A-Za-z0-9_-]{24}"$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func readRequestBody(request *http.Request, expectedMediaType string) ([]byte, error) {
|
||||||
|
mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
|
||||||
|
if err != nil || mediaType != expectedMediaType {
|
||||||
|
return nil, fmt.Errorf("Content-Type must be %s", expectedMediaType)
|
||||||
|
}
|
||||||
|
contents, err := io.ReadAll(io.LimitReader(request.Body, maximumRequestBody+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("read request body")
|
||||||
|
}
|
||||||
|
if len(contents) == 0 || len(contents) > maximumRequestBody {
|
||||||
|
return nil, errors.New("request body is empty or too large")
|
||||||
|
}
|
||||||
|
if err := rejectDuplicateJSONKeys(contents); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return contents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStrictJSON(contents []byte, destination any) error {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(contents))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(destination); err != nil {
|
||||||
|
return errors.New("request body does not match the API schema")
|
||||||
|
}
|
||||||
|
if decoder.Decode(&struct{}{}) != io.EOF {
|
||||||
|
return errors.New("request body contains trailing JSON")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectTopLevelNulls(contents []byte) error {
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(contents, &fields); err != nil || fields == nil {
|
||||||
|
return errors.New("request body must be a JSON object")
|
||||||
|
}
|
||||||
|
for _, value := range fields {
|
||||||
|
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||||
|
return errors.New("request body properties cannot be null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectDuplicateJSONKeys(contents []byte) error {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(contents))
|
||||||
|
decoder.UseNumber()
|
||||||
|
var visit func(int) error
|
||||||
|
visit = func(depth int) error {
|
||||||
|
if depth > 64 {
|
||||||
|
return errors.New("request body nesting is too deep")
|
||||||
|
}
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("request body is not valid JSON")
|
||||||
|
}
|
||||||
|
delimiter, ok := token.(json.Delim)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch delimiter {
|
||||||
|
case '{':
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for decoder.More() {
|
||||||
|
keyToken, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("request body is not valid JSON")
|
||||||
|
}
|
||||||
|
key, ok := keyToken.(string)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("request body is not a JSON object")
|
||||||
|
}
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
return errors.New("request body contains a duplicate property")
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
if err := visit(depth + 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end, err := decoder.Token()
|
||||||
|
if err != nil || end != json.Delim('}') {
|
||||||
|
return errors.New("request body is not valid JSON")
|
||||||
|
}
|
||||||
|
case '[':
|
||||||
|
for decoder.More() {
|
||||||
|
if err := visit(depth + 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end, err := decoder.Token()
|
||||||
|
if err != nil || end != json.Delim(']') {
|
||||||
|
return errors.New("request body is not valid JSON")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New("request body is not valid JSON")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := visit(0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := decoder.Token(); err != io.EOF {
|
||||||
|
return errors.New("request body contains trailing JSON")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validLogicalID(value string) bool {
|
||||||
|
return logicalIDRegex.MatchString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validLength(value string, minimum, maximum int) bool {
|
||||||
|
length := utf8.RuneCountInString(value)
|
||||||
|
return utf8.ValidString(value) && length >= minimum && length <= maximum
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateEndpoint(value string) bool {
|
||||||
|
if !validLength(value, 1, 2048) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
return err == nil && parsed.Scheme != "" && parsed.User == nil &&
|
||||||
|
!strings.ContainsAny(value, "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validStrongETag(value string) bool {
|
||||||
|
return strongETagRegex.MatchString(value) && value != "*" && !strings.Contains(value, ",")
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package controlapi
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// MarshalJSON preserves the OpenAPI-required nullable projection keys. The
|
||||||
|
// generator represents JSON null as nil pointers but marks read-only pointers
|
||||||
|
// omitempty, so the default encoder would otherwise violate the v1 wire shape.
|
||||||
|
func (value ProjectionVersions) MarshalJSON() ([]byte, error) {
|
||||||
|
type projectionWire struct {
|
||||||
|
QuotaSourceVersion *int64 `json:"quota_source_version"`
|
||||||
|
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
|
||||||
|
SyncedAt any `json:"synced_at"`
|
||||||
|
}
|
||||||
|
return json.Marshal(projectionWire{
|
||||||
|
QuotaSourceVersion: value.QuotaSourceVersion,
|
||||||
|
AreaPolicySourceVersion: value.AreaPolicySourceVersion,
|
||||||
|
SyncedAt: value.SyncedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// Package device contains the Sense device-ledger domain model.
|
||||||
|
package device
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultVideoChannels = 16
|
||||||
|
MaximumVideoChannels = 128
|
||||||
|
)
|
||||||
|
|
||||||
|
type Modality string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModalityVideo Modality = "video"
|
||||||
|
ModalityRadar Modality = "radar"
|
||||||
|
ModalityContact Modality = "contact"
|
||||||
|
ModalityButton Modality = "button"
|
||||||
|
ModalityWearable Modality = "wearable"
|
||||||
|
ModalityOther Modality = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Capability string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CapabilityVideoCapture Capability = "video_capture"
|
||||||
|
CapabilityAudioCapture Capability = "audio_capture"
|
||||||
|
CapabilitySpatialRule Capability = "spatial_rule"
|
||||||
|
CapabilityTelemetry Capability = "telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DesiredState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DesiredDisabled DesiredState = "disabled"
|
||||||
|
DesiredEnabled DesiredState = "enabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActualState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActualPending ActualState = "pending"
|
||||||
|
ActualOnline ActualState = "online"
|
||||||
|
ActualOffline ActualState = "offline"
|
||||||
|
ActualFailed ActualState = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Site struct {
|
||||||
|
TenantID string
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
MaxVideoChannels int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Site) ApplyDefaults() {
|
||||||
|
if s.MaxVideoChannels == 0 {
|
||||||
|
s.MaxVideoChannels = DefaultVideoChannels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Site) Validate() error {
|
||||||
|
if strings.TrimSpace(s.TenantID) == "" || strings.TrimSpace(s.ID) == "" {
|
||||||
|
return errors.New("tenant ID and site ID are required")
|
||||||
|
}
|
||||||
|
if s.MaxVideoChannels < 1 || s.MaxVideoChannels > MaximumVideoChannels {
|
||||||
|
return fmt.Errorf("max video channels must be between 1 and %d", MaximumVideoChannels)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Device struct {
|
||||||
|
ID string
|
||||||
|
TenantID string
|
||||||
|
SiteID string
|
||||||
|
AreaID string
|
||||||
|
SerialNumber string
|
||||||
|
Name string
|
||||||
|
Modality Modality
|
||||||
|
Capabilities []Capability
|
||||||
|
DesiredState DesiredState
|
||||||
|
ActualState ActualState
|
||||||
|
EndpointRef string
|
||||||
|
CredentialRef string
|
||||||
|
ProfileToken string
|
||||||
|
PathName string
|
||||||
|
Generation int64
|
||||||
|
ResourceVersion int64
|
||||||
|
QuotaSourceVersion int64
|
||||||
|
AreaPolicySourceVersion int64
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Device) Validate() error {
|
||||||
|
if strings.TrimSpace(d.ID) == "" || strings.TrimSpace(d.TenantID) == "" || strings.TrimSpace(d.SiteID) == "" {
|
||||||
|
return errors.New("device ID, tenant ID and site ID are required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.SerialNumber) == "" || strings.TrimSpace(d.Name) == "" {
|
||||||
|
return errors.New("serial number and device name are required")
|
||||||
|
}
|
||||||
|
if !validModality(d.Modality) {
|
||||||
|
return fmt.Errorf("unsupported modality %q", d.Modality)
|
||||||
|
}
|
||||||
|
if d.DesiredState != DesiredEnabled && d.DesiredState != DesiredDisabled {
|
||||||
|
return fmt.Errorf("unsupported desired state %q", d.DesiredState)
|
||||||
|
}
|
||||||
|
if d.ActualState != ActualPending && d.ActualState != ActualOnline && d.ActualState != ActualOffline && d.ActualState != ActualFailed {
|
||||||
|
return fmt.Errorf("unsupported actual state %q", d.ActualState)
|
||||||
|
}
|
||||||
|
if d.DesiredState == DesiredEnabled && d.HasCapability(CapabilityVideoCapture) {
|
||||||
|
if strings.TrimSpace(d.EndpointRef) == "" || strings.TrimSpace(d.PathName) == "" {
|
||||||
|
return errors.New("enabled video devices require endpoint ref and path name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.EndpointRef != "" {
|
||||||
|
endpoint, err := url.Parse(d.EndpointRef)
|
||||||
|
if err != nil || endpoint.Scheme == "" {
|
||||||
|
return errors.New("endpoint ref must be an absolute URI")
|
||||||
|
}
|
||||||
|
if endpoint.User != nil {
|
||||||
|
return errors.New("endpoint ref must not contain credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.PathName != "" && !validPathName(d.PathName) {
|
||||||
|
return errors.New("path name must contain safe ASCII segments")
|
||||||
|
}
|
||||||
|
seen := make(map[Capability]struct{}, len(d.Capabilities))
|
||||||
|
for _, capability := range d.Capabilities {
|
||||||
|
if !validCapability(capability) {
|
||||||
|
return fmt.Errorf("unsupported capability %q", capability)
|
||||||
|
}
|
||||||
|
if _, ok := seen[capability]; ok {
|
||||||
|
return fmt.Errorf("duplicate capability %q", capability)
|
||||||
|
}
|
||||||
|
seen[capability] = struct{}{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Device) HasCapability(capability Capability) bool {
|
||||||
|
return slices.Contains(d.Capabilities, capability)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Device) ConsumesVideoChannel() bool {
|
||||||
|
return d.DesiredState == DesiredEnabled && d.HasCapability(CapabilityVideoCapture)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validModality(value Modality) bool {
|
||||||
|
return slices.Contains([]Modality{ModalityVideo, ModalityRadar, ModalityContact, ModalityButton, ModalityWearable, ModalityOther}, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCapability(value Capability) bool {
|
||||||
|
return slices.Contains([]Capability{CapabilityVideoCapture, CapabilityAudioCapture, CapabilitySpatialRule, CapabilityTelemetry}, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPathName(value string) bool {
|
||||||
|
if strings.HasPrefix(value, "/") || strings.HasSuffix(value, "/") || strings.Contains(value, "//") || strings.Contains(value, "..") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
|
||||||
|
(character >= '0' && character <= '9') || strings.ContainsRune("-_/.", character) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return value != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuotaExceededError struct {
|
||||||
|
TenantID string
|
||||||
|
SiteID string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *QuotaExceededError) Error() string {
|
||||||
|
return fmt.Sprintf("video channel quota exceeded for site %s/%s (limit %d)", e.TenantID, e.SiteID, e.Limit)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package device
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDeviceRejectsCredentialsInEndpointReference(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
value := validVideoDevice()
|
||||||
|
value.EndpointRef = "http://" + "user" + ":" + "redacted" + "@camera.invalid/onvif"
|
||||||
|
if err := value.Validate(); err == nil {
|
||||||
|
t.Fatal("expected endpoint credentials to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceRejectsUnsafeMediaPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
value := validVideoDevice()
|
||||||
|
value.PathName = "tenant/../another-camera"
|
||||||
|
if err := value.Validate(); err == nil {
|
||||||
|
t.Fatal("expected unsafe path name to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validVideoDevice() Device {
|
||||||
|
return Device{
|
||||||
|
ID: "camera", TenantID: "tenant", SiteID: "site", SerialNumber: "serial", Name: "Camera",
|
||||||
|
Modality: ModalityVideo, Capabilities: []Capability{CapabilityVideoCapture},
|
||||||
|
DesiredState: DesiredEnabled, ActualState: ActualPending,
|
||||||
|
EndpointRef: "onvif://camera", PathName: "sense/tenant/site/camera",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Package metrics exposes a deliberately small, low-cardinality Prometheus
|
||||||
|
// surface without adding a runtime dependency. Tenant, Site, device and Path
|
||||||
|
// identifiers never become labels.
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateInstanceID() (string, error) {
|
||||||
|
value := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(value); err != nil {
|
||||||
|
return "", errors.New("generate Sense instance identifier")
|
||||||
|
}
|
||||||
|
return "ins_" + hex.EncodeToString(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
instanceID string
|
||||||
|
version string
|
||||||
|
|
||||||
|
reconcileRunOK atomic.Uint64
|
||||||
|
reconcileRunError atomic.Uint64
|
||||||
|
reconcileItemOK atomic.Uint64
|
||||||
|
reconcileItemError atomic.Uint64
|
||||||
|
reconcileItemLost atomic.Uint64
|
||||||
|
reconcileDurationNS atomic.Uint64
|
||||||
|
reconcileTotal atomic.Int64
|
||||||
|
reconcileUnconverged atomic.Int64
|
||||||
|
|
||||||
|
orphanScanOK atomic.Uint64
|
||||||
|
orphanScanError atomic.Uint64
|
||||||
|
orphanObserved atomic.Int64
|
||||||
|
orphanOwnedStale atomic.Int64
|
||||||
|
orphanUnowned atomic.Int64
|
||||||
|
orphanBlockedRatio atomic.Uint64
|
||||||
|
orphanBlockedAge atomic.Uint64
|
||||||
|
orphanBlockedScope atomic.Uint64
|
||||||
|
orphanDeleted atomic.Uint64
|
||||||
|
orphanDeleteFailed atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(instanceID, version string) *Registry {
|
||||||
|
return &Registry{instanceID: instanceID, version: version}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) ObserveReconcileRun(err error, duration time.Duration) {
|
||||||
|
if err == nil {
|
||||||
|
r.reconcileRunOK.Add(1)
|
||||||
|
} else {
|
||||||
|
r.reconcileRunError.Add(1)
|
||||||
|
}
|
||||||
|
if duration < 0 {
|
||||||
|
duration = 0
|
||||||
|
}
|
||||||
|
r.reconcileDurationNS.Store(uint64(duration))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) ObserveReconcileItem(result string) {
|
||||||
|
switch result {
|
||||||
|
case "success":
|
||||||
|
r.reconcileItemOK.Add(1)
|
||||||
|
case "lease_lost":
|
||||||
|
r.reconcileItemLost.Add(1)
|
||||||
|
default:
|
||||||
|
r.reconcileItemError.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) SetConvergence(total, unconverged int) {
|
||||||
|
r.reconcileTotal.Store(int64(total))
|
||||||
|
r.reconcileUnconverged.Store(int64(unconverged))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) ObserveOrphanScan(observed, ownedStale, unowned int, err error) {
|
||||||
|
if err == nil {
|
||||||
|
r.orphanScanOK.Add(1)
|
||||||
|
r.orphanObserved.Store(int64(observed))
|
||||||
|
r.orphanOwnedStale.Store(int64(ownedStale))
|
||||||
|
r.orphanUnowned.Store(int64(unowned))
|
||||||
|
} else {
|
||||||
|
r.orphanScanError.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) ObserveOrphanCleanupBlocked(reason string) {
|
||||||
|
switch reason {
|
||||||
|
case "snapshot_expired":
|
||||||
|
r.orphanBlockedAge.Add(1)
|
||||||
|
case "ratio_exceeded":
|
||||||
|
r.orphanBlockedRatio.Add(1)
|
||||||
|
default:
|
||||||
|
r.orphanBlockedScope.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) ObserveOrphanCleanup(deleted, failed int) {
|
||||||
|
if deleted > 0 {
|
||||||
|
r.orphanDeleted.Add(uint64(deleted))
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
r.orphanDeleteFailed.Add(uint64(failed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Handler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
|
writer.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = writer.Write([]byte(r.render()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) render() string {
|
||||||
|
var output strings.Builder
|
||||||
|
writeMetric(&output, "sense_build_info", "Sense process build and instance information.",
|
||||||
|
fmt.Sprintf("{instance_id=%s,version=%s}", quoteLabel(r.instanceID), quoteLabel(r.version)), 1)
|
||||||
|
writeMetric(&output, "sense_reconcile_runs_total", "Reconciliation runs by result.",
|
||||||
|
`{result="success"}`, r.reconcileRunOK.Load())
|
||||||
|
writeSample(&output, "sense_reconcile_runs_total", `{result="error"}`, r.reconcileRunError.Load())
|
||||||
|
writeMetric(&output, "sense_reconcile_items_total", "Reconciliation items by fixed result.",
|
||||||
|
`{result="success"}`, r.reconcileItemOK.Load())
|
||||||
|
writeSample(&output, "sense_reconcile_items_total", `{result="error"}`, r.reconcileItemError.Load())
|
||||||
|
writeSample(&output, "sense_reconcile_items_total", `{result="lease_lost"}`, r.reconcileItemLost.Load())
|
||||||
|
writeMetric(&output, "sense_reconcile_last_duration_seconds", "Duration of the last reconciliation run.",
|
||||||
|
"", float64(r.reconcileDurationNS.Load())/float64(time.Second))
|
||||||
|
writeMetric(&output, "sense_reconcile_devices", "Enabled video devices by convergence state.",
|
||||||
|
`{state="total"}`, r.reconcileTotal.Load())
|
||||||
|
writeSample(&output, "sense_reconcile_devices", `{state="unconverged"}`, r.reconcileUnconverged.Load())
|
||||||
|
writeMetric(&output, "sense_orphan_scan_runs_total", "MediaMTX orphan scans by result.",
|
||||||
|
`{result="success"}`, r.orphanScanOK.Load())
|
||||||
|
writeSample(&output, "sense_orphan_scan_runs_total", `{result="error"}`, r.orphanScanError.Load())
|
||||||
|
writeMetric(&output, "sense_orphan_paths", "Path counts from the last successful orphan scan.",
|
||||||
|
`{classification="observed"}`, r.orphanObserved.Load())
|
||||||
|
writeSample(&output, "sense_orphan_paths", `{classification="owned_stale"}`, r.orphanOwnedStale.Load())
|
||||||
|
writeSample(&output, "sense_orphan_paths", `{classification="unowned"}`, r.orphanUnowned.Load())
|
||||||
|
writeMetric(&output, "sense_orphan_cleanup_blocked_total", "Orphan cleanups blocked by a fixed safety reason.",
|
||||||
|
`{reason="ratio_exceeded"}`, r.orphanBlockedRatio.Load())
|
||||||
|
writeSample(&output, "sense_orphan_cleanup_blocked_total", `{reason="snapshot_expired"}`, r.orphanBlockedAge.Load())
|
||||||
|
writeSample(&output, "sense_orphan_cleanup_blocked_total", `{reason="scope_changed"}`, r.orphanBlockedScope.Load())
|
||||||
|
writeMetric(&output, "sense_orphan_cleanup_items_total", "Owned stale path cleanup results.",
|
||||||
|
`{result="deleted"}`, r.orphanDeleted.Load())
|
||||||
|
writeSample(&output, "sense_orphan_cleanup_items_total", `{result="failed"}`, r.orphanDeleteFailed.Load())
|
||||||
|
return output.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMetric(builder *strings.Builder, name, help, labels string, value any) {
|
||||||
|
metricType := "gauge"
|
||||||
|
if strings.HasSuffix(name, "_total") {
|
||||||
|
metricType = "counter"
|
||||||
|
}
|
||||||
|
builder.WriteString("# HELP ")
|
||||||
|
builder.WriteString(name)
|
||||||
|
builder.WriteByte(' ')
|
||||||
|
builder.WriteString(help)
|
||||||
|
builder.WriteByte('\n')
|
||||||
|
builder.WriteString("# TYPE ")
|
||||||
|
builder.WriteString(name)
|
||||||
|
builder.WriteByte(' ')
|
||||||
|
builder.WriteString(metricType)
|
||||||
|
builder.WriteByte('\n')
|
||||||
|
writeSample(builder, name, labels, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSample(builder *strings.Builder, name, labels string, value any) {
|
||||||
|
builder.WriteString(name)
|
||||||
|
builder.WriteString(labels)
|
||||||
|
builder.WriteByte(' ')
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case float64:
|
||||||
|
builder.WriteString(strconv.FormatFloat(typed, 'f', 6, 64))
|
||||||
|
default:
|
||||||
|
builder.WriteString(fmt.Sprint(typed))
|
||||||
|
}
|
||||||
|
builder.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteLabel(value string) string {
|
||||||
|
replacer := strings.NewReplacer(`\`, `\\`, "\n", `\n`, `"`, `\"`)
|
||||||
|
return `"` + replacer.Replace(value) + `"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandlerUsesOnlyFixedLowCardinalityLabels(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
registry := New("ins_test-1", "v-test")
|
||||||
|
registry.ObserveReconcileRun(nil, 1500*time.Millisecond)
|
||||||
|
registry.ObserveReconcileItem("success")
|
||||||
|
registry.ObserveReconcileItem("lease_lost")
|
||||||
|
registry.SetConvergence(16, 2)
|
||||||
|
registry.ObserveOrphanScan(20, 1, 3, nil)
|
||||||
|
registry.ObserveOrphanCleanupBlocked("ratio_exceeded")
|
||||||
|
registry.ObserveOrphanCleanup(1, 1)
|
||||||
|
|
||||||
|
request := httptest.NewRequest("GET", "/metrics", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
registry.Handler().ServeHTTP(response, request)
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, expected := range []string{
|
||||||
|
`sense_build_info{instance_id="ins_test-1",version="v-test"} 1`,
|
||||||
|
`sense_reconcile_items_total{result="lease_lost"} 1`,
|
||||||
|
`sense_reconcile_devices{state="unconverged"} 2`,
|
||||||
|
`sense_orphan_paths{classification="owned_stale"} 1`,
|
||||||
|
`sense_orphan_cleanup_blocked_total{reason="ratio_exceeded"} 1`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, expected) {
|
||||||
|
t.Fatalf("metrics output lacks %q:\n%s", expected, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{
|
||||||
|
"tenant_id", "site_id", "device_id", "path_name", "rtsp://", "credential",
|
||||||
|
} {
|
||||||
|
if strings.Contains(body, forbidden) {
|
||||||
|
t.Fatalf("high-cardinality or secret-bearing label appeared: %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if response.Header().Get("Cache-Control") != "no-store" {
|
||||||
|
t.Fatal("metrics responses must not be cached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryIsSafeForConcurrentObservers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
registry := New("ins_concurrent", "dev")
|
||||||
|
var wait sync.WaitGroup
|
||||||
|
for index := 0; index < 32; index++ {
|
||||||
|
wait.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
for count := 0; count < 100; count++ {
|
||||||
|
registry.ObserveReconcileItem("success")
|
||||||
|
registry.SetConvergence(128, count%17)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wait.Wait()
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
registry.Handler().ServeHTTP(response, httptest.NewRequest("GET", "/metrics", nil))
|
||||||
|
if !strings.Contains(response.Body.String(), `sense_reconcile_items_total{result="success"} 3200`) {
|
||||||
|
t.Fatalf("concurrent counter lost updates: %s", response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateInstanceIDShape(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
first, err := GenerateInstanceID()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := GenerateInstanceID()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(first) != 28 || !strings.HasPrefix(first, "ins_") || first == second {
|
||||||
|
t.Fatalf("unexpected instance identifiers: %q %q", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package mtx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
mediamtxapi "yovision/sense/internal/mtx/generated"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrPathNotFound = errors.New("MediaMTX path not found")
|
||||||
|
|
||||||
|
type APIError struct {
|
||||||
|
Operation string
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
return fmt.Sprintf("MediaMTX %s failed with HTTP status %d", e.Operation, e.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PathConfig struct {
|
||||||
|
Name string
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
type pathAPI interface {
|
||||||
|
ConfigPathsAddWithResponse(context.Context, string, mediamtxapi.ConfigPathsAddJSONRequestBody, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsAddResponse, error)
|
||||||
|
ConfigPathsGetWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsGetResponse, error)
|
||||||
|
ConfigPathsPatchWithResponse(context.Context, string, mediamtxapi.ConfigPathsPatchJSONRequestBody, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsPatchResponse, error)
|
||||||
|
ConfigPathsDeleteWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsDeleteResponse, error)
|
||||||
|
ConfigPathsListWithResponse(context.Context, *mediamtxapi.ConfigPathsListParams, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsListResponse, error)
|
||||||
|
PathsGetWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.PathsGetResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPathNames enumerates only configuration names. Sources are deliberately
|
||||||
|
// discarded so inventory and orphan reports cannot expose stream URIs.
|
||||||
|
func (c *Client) ListPathNames(ctx context.Context) ([]string, error) {
|
||||||
|
const (
|
||||||
|
itemsPerPage = 100
|
||||||
|
maxPages = 1000
|
||||||
|
)
|
||||||
|
result := make(map[string]struct{})
|
||||||
|
seenPages := make(map[string]struct{})
|
||||||
|
for page := 0; page < maxPages; page++ {
|
||||||
|
pageValue, limitValue := page, itemsPerPage
|
||||||
|
response, err := c.api.ConfigPathsListWithResponse(ctx, &mediamtxapi.ConfigPathsListParams{
|
||||||
|
Page: &pageValue, ItemsPerPage: &limitValue,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("MediaMTX list paths transport: %w", err)
|
||||||
|
}
|
||||||
|
if response.StatusCode() != http.StatusOK || response.JSON200 == nil ||
|
||||||
|
response.JSON200.PageCount == nil || response.JSON200.Items == nil {
|
||||||
|
return nil, &APIError{Operation: "list paths", StatusCode: response.StatusCode()}
|
||||||
|
}
|
||||||
|
pageCount := *response.JSON200.PageCount
|
||||||
|
if pageCount < 0 || pageCount > maxPages {
|
||||||
|
return nil, &APIError{Operation: "list paths pagination", StatusCode: response.StatusCode()}
|
||||||
|
}
|
||||||
|
pageNames := make([]string, 0, len(*response.JSON200.Items))
|
||||||
|
for _, item := range *response.JSON200.Items {
|
||||||
|
if item.Name == nil || strings.TrimSpace(*item.Name) == "" {
|
||||||
|
return nil, &APIError{Operation: "list paths response", StatusCode: response.StatusCode()}
|
||||||
|
}
|
||||||
|
pageNames = append(pageNames, *item.Name)
|
||||||
|
result[*item.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
sort.Strings(pageNames)
|
||||||
|
signature := strings.Join(pageNames, "\x00")
|
||||||
|
if page > 0 && signature != "" {
|
||||||
|
if _, duplicate := seenPages[signature]; duplicate {
|
||||||
|
return nil, &APIError{Operation: "list paths repeated page", StatusCode: response.StatusCode()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seenPages[signature] = struct{}{}
|
||||||
|
if int64(page+1) >= pageCount {
|
||||||
|
values := make([]string, 0, len(result))
|
||||||
|
for name := range result {
|
||||||
|
values = append(values, name)
|
||||||
|
}
|
||||||
|
sort.Strings(values)
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, &APIError{Operation: "list paths page limit", StatusCode: http.StatusOK}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
api pathAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
|
||||||
|
options := make([]mediamtxapi.ClientOption, 0, 1)
|
||||||
|
if httpClient != nil {
|
||||||
|
options = append(options, mediamtxapi.WithHTTPClient(httpClient))
|
||||||
|
}
|
||||||
|
generated, err := mediamtxapi.NewClientWithResponses(strings.TrimRight(baseURL, "/"), options...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create MediaMTX client: %w", err)
|
||||||
|
}
|
||||||
|
return &Client{api: generated}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClientWithAPI(api pathAPI) *Client {
|
||||||
|
return &Client{api: api}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreatePath(ctx context.Context, name, source string) error {
|
||||||
|
response, err := c.api.ConfigPathsAddWithResponse(ctx, name, mediamtxapi.PathConf{Source: &source})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("MediaMTX create path transport: %w", err)
|
||||||
|
}
|
||||||
|
if response.StatusCode() != http.StatusOK {
|
||||||
|
return &APIError{Operation: "create path", StatusCode: response.StatusCode()}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetPath(ctx context.Context, name string) (PathConfig, error) {
|
||||||
|
response, err := c.api.ConfigPathsGetWithResponse(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return PathConfig{}, fmt.Errorf("MediaMTX read path transport: %w", err)
|
||||||
|
}
|
||||||
|
if response.StatusCode() == http.StatusNotFound {
|
||||||
|
return PathConfig{}, ErrPathNotFound
|
||||||
|
}
|
||||||
|
if response.StatusCode() != http.StatusOK || response.JSON200 == nil {
|
||||||
|
return PathConfig{}, &APIError{Operation: "read path", StatusCode: response.StatusCode()}
|
||||||
|
}
|
||||||
|
result := PathConfig{Name: name}
|
||||||
|
if response.JSON200.Name != nil {
|
||||||
|
result.Name = *response.JSON200.Name
|
||||||
|
}
|
||||||
|
if response.JSON200.Source != nil {
|
||||||
|
result.Source = *response.JSON200.Source
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeletePath(ctx context.Context, name string) error {
|
||||||
|
response, err := c.api.ConfigPathsDeleteWithResponse(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("MediaMTX delete path transport: %w", err)
|
||||||
|
}
|
||||||
|
if response.StatusCode() == http.StatusNotFound {
|
||||||
|
// Deletion is an idempotent convergence operation. A missing exact path
|
||||||
|
// already satisfies the disabled desired state.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if response.StatusCode() != http.StatusOK {
|
||||||
|
return &APIError{Operation: "delete path", StatusCode: response.StatusCode()}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsurePath converges one desired path. It never enumerates or deletes orphans.
|
||||||
|
func (c *Client) EnsurePath(ctx context.Context, name, source string) (bool, error) {
|
||||||
|
current, err := c.GetPath(ctx, name)
|
||||||
|
if errors.Is(err, ErrPathNotFound) {
|
||||||
|
if err := c.CreatePath(ctx, name, source); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if current.Source == source {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
response, err := c.api.ConfigPathsPatchWithResponse(ctx, name, mediamtxapi.PathConf{Source: &source})
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("MediaMTX patch path transport: %w", err)
|
||||||
|
}
|
||||||
|
if response.StatusCode() != http.StatusOK {
|
||||||
|
return false, &APIError{Operation: "patch path", StatusCode: response.StatusCode()}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) PathReady(ctx context.Context, name string) (bool, error) {
|
||||||
|
response, err := c.api.PathsGetWithResponse(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("MediaMTX probe path transport: %w", err)
|
||||||
|
}
|
||||||
|
if response.StatusCode() == http.StatusNotFound {
|
||||||
|
return false, ErrPathNotFound
|
||||||
|
}
|
||||||
|
if response.StatusCode() != http.StatusOK || response.JSON200 == nil {
|
||||||
|
return false, &APIError{Operation: "probe path", StatusCode: response.StatusCode()}
|
||||||
|
}
|
||||||
|
if response.JSON200.Online == nil || response.JSON200.Available == nil {
|
||||||
|
return false, &APIError{Operation: "probe path response", StatusCode: response.StatusCode()}
|
||||||
|
}
|
||||||
|
return *response.JSON200.Online && *response.JSON200.Available, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package mtx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeMediaMTX struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
paths map[string]string
|
||||||
|
mutations int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMediaMTX) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
if request.URL.Path == "/v3/config/paths/list" {
|
||||||
|
page, _ := strconv.Atoi(request.URL.Query().Get("page"))
|
||||||
|
limit, _ := strconv.Atoi(request.URL.Query().Get("itemsPerPage"))
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(f.paths))
|
||||||
|
for name := range f.paths {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
start := page * limit
|
||||||
|
if start > len(names) {
|
||||||
|
start = len(names)
|
||||||
|
}
|
||||||
|
end := start + limit
|
||||||
|
if end > len(names) {
|
||||||
|
end = len(names)
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0, end-start)
|
||||||
|
for _, name := range names[start:end] {
|
||||||
|
items = append(items, map[string]any{"name": name})
|
||||||
|
}
|
||||||
|
pageCount := 0
|
||||||
|
if len(names) > 0 {
|
||||||
|
pageCount = (len(names) + limit - 1) / limit
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(writer).Encode(map[string]any{
|
||||||
|
"itemCount": len(names), "pageCount": pageCount, "items": items,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefixes := map[string]string{
|
||||||
|
"/v3/config/paths/get/": "get",
|
||||||
|
"/v3/config/paths/add/": "add",
|
||||||
|
"/v3/config/paths/patch/": "patch",
|
||||||
|
"/v3/config/paths/delete/": "delete",
|
||||||
|
"/v3/paths/get/": "runtime",
|
||||||
|
}
|
||||||
|
for prefix, operation := range prefixes {
|
||||||
|
if !strings.HasPrefix(request.URL.Path, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimPrefix(request.URL.Path, prefix)
|
||||||
|
source, exists := f.paths[name]
|
||||||
|
switch operation {
|
||||||
|
case "get":
|
||||||
|
if !exists {
|
||||||
|
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(writer).Encode(map[string]any{"name": name, "source": source})
|
||||||
|
case "add", "patch":
|
||||||
|
var body struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(writer, `{}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.paths[name] = body.Source
|
||||||
|
f.mutations++
|
||||||
|
_, _ = writer.Write([]byte(`{}`))
|
||||||
|
case "delete":
|
||||||
|
if !exists {
|
||||||
|
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(f.paths, name)
|
||||||
|
f.mutations++
|
||||||
|
_, _ = writer.Write([]byte(`{}`))
|
||||||
|
case "runtime":
|
||||||
|
if !exists {
|
||||||
|
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = writer.Write([]byte(`{"online":true,"available":true}`))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPathNamesUsesPaginationAndDoesNotReturnSources(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
paths := make(map[string]string)
|
||||||
|
for index := 0; index < 205; index++ {
|
||||||
|
paths[fmt.Sprintf("camera-%03d", index)] = fmt.Sprintf("rtsp://secret.invalid/%d", index)
|
||||||
|
}
|
||||||
|
fake := &fakeMediaMTX{paths: paths}
|
||||||
|
server := httptest.NewServer(fake)
|
||||||
|
defer server.Close()
|
||||||
|
client, err := NewClient(server.URL, server.Client())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
values, err := client.ListPathNames(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(values) != 205 || values[0] != "camera-000" || values[204] != "camera-204" {
|
||||||
|
t.Fatalf("unexpected path inventory: len=%d first=%q last=%q", len(values), values[0], values[len(values)-1])
|
||||||
|
}
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.Contains(value, "rtsp") || strings.Contains(value, "secret") {
|
||||||
|
t.Fatalf("source leaked from path inventory: %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPathNamesRejectsRepeatedPage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
if request.URL.Path != "/v3/config/paths/list" {
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = writer.Write([]byte(`{"itemCount":2,"pageCount":2,"items":[{"name":"same"}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := NewClient(server.URL, server.Client())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := client.ListPathNames(context.Background()); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "repeated page") {
|
||||||
|
t.Fatalf("repeated pagination was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedClientCreateReadDeleteMapping(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fake := &fakeMediaMTX{paths: make(map[string]string)}
|
||||||
|
server := httptest.NewServer(fake)
|
||||||
|
defer server.Close()
|
||||||
|
client, err := NewClient(server.URL, server.Client())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := client.CreatePath(ctx, "camera-1", "rtsp://media.invalid/camera-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path, err := client.GetPath(ctx, "camera-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if path.Source != "rtsp://media.invalid/camera-1" {
|
||||||
|
t.Fatalf("unexpected source mapping: %+v", path)
|
||||||
|
}
|
||||||
|
if err := client.DeletePath(ctx, "camera-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := client.GetPath(ctx, "camera-1"); err != ErrPathNotFound {
|
||||||
|
t.Fatalf("expected not found after delete, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePathIsIdempotentAndCanPatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fake := &fakeMediaMTX{paths: make(map[string]string)}
|
||||||
|
server := httptest.NewServer(fake)
|
||||||
|
defer server.Close()
|
||||||
|
client, err := NewClient(server.URL, server.Client())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
changed, err := client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/first")
|
||||||
|
if err != nil || !changed {
|
||||||
|
t.Fatalf("first ensure: changed=%v err=%v", changed, err)
|
||||||
|
}
|
||||||
|
changed, err = client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/first")
|
||||||
|
if err != nil || changed {
|
||||||
|
t.Fatalf("second ensure must be idempotent: changed=%v err=%v", changed, err)
|
||||||
|
}
|
||||||
|
changed, err = client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/second")
|
||||||
|
if err != nil || !changed {
|
||||||
|
t.Fatalf("changed source must patch: changed=%v err=%v", changed, err)
|
||||||
|
}
|
||||||
|
if fake.mutations != 2 {
|
||||||
|
t.Fatalf("expected create + patch, got %d mutations", fake.mutations)
|
||||||
|
}
|
||||||
|
ready, err := client.PathReady(ctx, "camera-2")
|
||||||
|
if err != nil || !ready {
|
||||||
|
t.Fatalf("runtime probe: ready=%v err=%v", ready, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIErrorDoesNotLeakSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = fmt.Fprint(writer, `{"error":"upstream included a secret"}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client, err := NewClient(server.URL, server.Client())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
secretSource := "rtsp://" + "user" + ":" + "redacted" + "@camera.invalid/live"
|
||||||
|
err = client.CreatePath(context.Background(), "camera", secretSource)
|
||||||
|
if err == nil || strings.Contains(err.Error(), secretSource) || strings.Contains(err.Error(), "secret") {
|
||||||
|
t.Fatalf("error must be redacted, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package mtx wraps the generated MediaMTX control API client.
|
||||||
|
package mtx
|
||||||
|
|
||||||
|
// The input is the official API document vendored from the frozen MediaMTX tag.
|
||||||
|
//go:generate go tool oapi-codegen -config oapi-codegen.yaml ../../api/vendor/mediamtx-v1.19.3.openapi.yaml
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
package: mediamtxapi
|
||||||
|
output: generated/client.gen.go
|
||||||
|
generate:
|
||||||
|
models: true
|
||||||
|
client: true
|
||||||
|
output-options:
|
||||||
|
skip-prune: false
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package onvif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Credentials struct {
|
||||||
|
ONVIFUsername string
|
||||||
|
ONVIFPassword string
|
||||||
|
RTSPUsername string
|
||||||
|
RTSPPassword string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CredentialProvider interface {
|
||||||
|
Resolve(reference string) (Credentials, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvCredentials struct {
|
||||||
|
LookupEnv func(string) (string, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentialKey = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||||
|
|
||||||
|
func (p EnvCredentials) Resolve(reference string) (Credentials, error) {
|
||||||
|
parsed, err := url.Parse(reference)
|
||||||
|
if err != nil || parsed.Scheme != "env" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
|
return Credentials{}, fmt.Errorf("credential reference must use env://<key>")
|
||||||
|
}
|
||||||
|
key := strings.Trim(strings.TrimSpace(parsed.Host+parsed.Path), "/")
|
||||||
|
if !credentialKey.MatchString(key) {
|
||||||
|
return Credentials{}, fmt.Errorf("credential reference contains an invalid key")
|
||||||
|
}
|
||||||
|
lookup := p.LookupEnv
|
||||||
|
if lookup == nil {
|
||||||
|
lookup = os.LookupEnv
|
||||||
|
}
|
||||||
|
prefix := "SENSE_CREDENTIAL_" + strings.ToUpper(strings.ReplaceAll(key, "-", "_"))
|
||||||
|
read := func(suffix string) string {
|
||||||
|
value, _ := lookup(prefix + suffix)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
result := Credentials{
|
||||||
|
ONVIFUsername: read("_ONVIF_USERNAME"),
|
||||||
|
ONVIFPassword: read("_ONVIF_PASSWORD"),
|
||||||
|
RTSPUsername: read("_RTSP_USERNAME"),
|
||||||
|
RTSPPassword: read("_RTSP_PASSWORD"),
|
||||||
|
}
|
||||||
|
if result.ONVIFUsername == "" || result.ONVIFPassword == "" {
|
||||||
|
return Credentials{}, fmt.Errorf("ONVIF credentials are not configured for reference")
|
||||||
|
}
|
||||||
|
if result.RTSPUsername == "" {
|
||||||
|
result.RTSPUsername = result.ONVIFUsername
|
||||||
|
}
|
||||||
|
if result.RTSPPassword == "" {
|
||||||
|
result.RTSPPassword = result.ONVIFPassword
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package onvif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FakeScenario struct {
|
||||||
|
Result ProbeResult `json:"result"`
|
||||||
|
ProbeError ErrorCode `json:"probe_error,omitempty"`
|
||||||
|
ClockError ErrorCode `json:"clock_error,omitempty"`
|
||||||
|
DelayMillis int `json:"delay_millis,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeFixture struct {
|
||||||
|
Scenarios map[string]FakeScenario `json:"scenarios"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fake is deterministic and intended only for tests and offline development.
|
||||||
|
type Fake struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
scenarios map[string]FakeScenario
|
||||||
|
probeCalls map[string]int
|
||||||
|
clockSyncCalls map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFake(scenarios map[string]FakeScenario) *Fake {
|
||||||
|
copyOfScenarios := make(map[string]FakeScenario, len(scenarios))
|
||||||
|
for key, value := range scenarios {
|
||||||
|
copyOfScenarios[key] = value
|
||||||
|
}
|
||||||
|
return &Fake{
|
||||||
|
scenarios: copyOfScenarios, probeCalls: make(map[string]int), clockSyncCalls: make(map[string]int),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadFakeFixture(path string) (*Fake, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read ONVIF fixture: %w", err)
|
||||||
|
}
|
||||||
|
var fixture fakeFixture
|
||||||
|
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode ONVIF fixture: %w", err)
|
||||||
|
}
|
||||||
|
return NewFake(fixture.Scenarios), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) Probe(ctx context.Context, target Target) (ProbeResult, error) {
|
||||||
|
scenario, ok := f.scenario(target.EndpointRef)
|
||||||
|
if !ok {
|
||||||
|
return ProbeResult{}, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("fixture endpoint is not configured")}
|
||||||
|
}
|
||||||
|
if err := waitForFakeDelay(ctx, scenario.DelayMillis); err != nil {
|
||||||
|
return ProbeResult{}, &Error{Code: ErrorTimeout, Err: err}
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.probeCalls[target.EndpointRef]++
|
||||||
|
f.mu.Unlock()
|
||||||
|
if scenario.ProbeError != "" {
|
||||||
|
return ProbeResult{}, &Error{Code: scenario.ProbeError, Err: fmt.Errorf("fixture probe failure")}
|
||||||
|
}
|
||||||
|
if scenario.Result.StreamURI == "" || len(scenario.Result.Profiles) == 0 {
|
||||||
|
return ProbeResult{}, &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("fixture lacks profile or stream URI")}
|
||||||
|
}
|
||||||
|
return scenario.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) SetSystemDateAndTime(ctx context.Context, target Target, _ time.Time) error {
|
||||||
|
scenario, ok := f.scenario(target.EndpointRef)
|
||||||
|
if !ok {
|
||||||
|
return &Error{Code: ErrorUnavailable, Err: fmt.Errorf("fixture endpoint is not configured")}
|
||||||
|
}
|
||||||
|
if err := waitForFakeDelay(ctx, scenario.DelayMillis); err != nil {
|
||||||
|
return &Error{Code: ErrorTimeout, Err: err}
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.clockSyncCalls[target.EndpointRef]++
|
||||||
|
f.mu.Unlock()
|
||||||
|
if scenario.ClockError != "" {
|
||||||
|
return &Error{Code: scenario.ClockError, Err: fmt.Errorf("fixture clock failure")}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) ProbeCalls(endpointRef string) int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.probeCalls[endpointRef]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) ClockSyncCalls(endpointRef string) int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.clockSyncCalls[endpointRef]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) scenario(endpointRef string) (FakeScenario, bool) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
scenario, ok := f.scenarios[endpointRef]
|
||||||
|
return scenario, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForFakeDelay(ctx context.Context, milliseconds int) error {
|
||||||
|
if milliseconds <= 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(time.Duration(milliseconds) * time.Millisecond)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package onvif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFakeMapsProfilesStreamAndClockSync(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target := Target{EndpointRef: "onvif://camera-ok", CredentialRef: "secret://camera-ok"}
|
||||||
|
result, err := fake.Probe(context.Background(), target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result.Profiles) != 2 || result.StreamURI != "rtsp://media.invalid/camera-ok" {
|
||||||
|
t.Fatalf("unexpected fixture mapping: %+v", result)
|
||||||
|
}
|
||||||
|
if err := fake.SetSystemDateAndTime(context.Background(), target, time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fake.ProbeCalls(target.EndpointRef) != 1 || fake.ClockSyncCalls(target.EndpointRef) != 1 {
|
||||||
|
t.Fatal("expected one probe and one clock-sync call")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeMapsAuthenticationFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = fake.Probe(context.Background(), Target{EndpointRef: "onvif://camera-auth"})
|
||||||
|
if CodeOf(err) != ErrorAuthentication {
|
||||||
|
t.Fatalf("expected authentication error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeHonorsCancellationAsTimeout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_, err = fake.Probe(ctx, Target{EndpointRef: "onvif://camera-slow"})
|
||||||
|
if CodeOf(err) != ErrorTimeout {
|
||||||
|
t.Fatalf("expected timeout error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package onvif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
deviceNamespace = "http://www.onvif.org/ver10/device/wsdl"
|
||||||
|
mediaNamespace = "http://www.onvif.org/ver10/media/wsdl"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HTTPOptions struct {
|
||||||
|
RTSPRewriteHost string
|
||||||
|
RTSPRewritePort int
|
||||||
|
StripRTSPQuery bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPAdapter struct {
|
||||||
|
credentials CredentialProvider
|
||||||
|
client *http.Client
|
||||||
|
options HTTPOptions
|
||||||
|
now func() time.Time
|
||||||
|
random io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPAdapter(credentials CredentialProvider, client *http.Client, options HTTPOptions) *HTTPAdapter {
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
}
|
||||||
|
return &HTTPAdapter{
|
||||||
|
credentials: credentials,
|
||||||
|
client: client,
|
||||||
|
options: options,
|
||||||
|
now: time.Now,
|
||||||
|
random: rand.Reader,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HTTPAdapter) Probe(ctx context.Context, target Target) (ProbeResult, error) {
|
||||||
|
endpoint, credentials, err := a.target(target)
|
||||||
|
if err != nil {
|
||||||
|
return ProbeResult{}, err
|
||||||
|
}
|
||||||
|
infoBody, err := a.call(ctx, endpoint, deviceNamespace+"/GetDeviceInformation",
|
||||||
|
`<tds:GetDeviceInformation xmlns:tds="`+deviceNamespace+`"/>`, credentials)
|
||||||
|
if err != nil {
|
||||||
|
return ProbeResult{}, err
|
||||||
|
}
|
||||||
|
var info deviceInformationEnvelope
|
||||||
|
if err := xml.Unmarshal(infoBody, &info); err != nil {
|
||||||
|
return ProbeResult{}, invalidResponse("decode device information")
|
||||||
|
}
|
||||||
|
|
||||||
|
servicesBody, err := a.call(ctx, endpoint, deviceNamespace+"/GetServices",
|
||||||
|
`<tds:GetServices xmlns:tds="`+deviceNamespace+`"><tds:IncludeCapability>false</tds:IncludeCapability></tds:GetServices>`, credentials)
|
||||||
|
if err != nil {
|
||||||
|
return ProbeResult{}, err
|
||||||
|
}
|
||||||
|
var services servicesEnvelope
|
||||||
|
if err := xml.Unmarshal(servicesBody, &services); err != nil {
|
||||||
|
return ProbeResult{}, invalidResponse("decode services")
|
||||||
|
}
|
||||||
|
mediaEndpoint, err := externalMediaEndpoint(endpoint, services.Body.Response.Services)
|
||||||
|
if err != nil {
|
||||||
|
return ProbeResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
profilesBody, err := a.call(ctx, mediaEndpoint, mediaNamespace+"/GetProfiles",
|
||||||
|
`<trt:GetProfiles xmlns:trt="`+mediaNamespace+`"/>`, credentials)
|
||||||
|
if err != nil {
|
||||||
|
return ProbeResult{}, err
|
||||||
|
}
|
||||||
|
var profilesResponse profilesEnvelope
|
||||||
|
if err := xml.Unmarshal(profilesBody, &profilesResponse); err != nil {
|
||||||
|
return ProbeResult{}, invalidResponse("decode profiles")
|
||||||
|
}
|
||||||
|
profiles := make([]Profile, 0, len(profilesResponse.Body.Response.Profiles))
|
||||||
|
selectedToken := ""
|
||||||
|
for _, value := range profilesResponse.Body.Response.Profiles {
|
||||||
|
video := value.VideoEncoder != nil
|
||||||
|
profiles = append(profiles, Profile{Token: value.Token, Name: value.Name, VideoEncoder: video})
|
||||||
|
if selectedToken == "" && video && value.Token != "" {
|
||||||
|
selectedToken = value.Token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedToken == "" {
|
||||||
|
return ProbeResult{}, invalidResponse("no video profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
streamRequest := `<trt:GetStreamUri xmlns:trt="` + mediaNamespace + `" xmlns:tt="http://www.onvif.org/ver10/schema">` +
|
||||||
|
`<trt:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport></trt:StreamSetup>` +
|
||||||
|
`<trt:ProfileToken>` + escapeXML(selectedToken) + `</trt:ProfileToken></trt:GetStreamUri>`
|
||||||
|
streamBody, err := a.call(ctx, mediaEndpoint, mediaNamespace+"/GetStreamUri", streamRequest, credentials)
|
||||||
|
if err != nil {
|
||||||
|
return ProbeResult{}, err
|
||||||
|
}
|
||||||
|
var streamResponse streamURIEnvelope
|
||||||
|
if err := xml.Unmarshal(streamBody, &streamResponse); err != nil {
|
||||||
|
return ProbeResult{}, invalidResponse("decode stream URI")
|
||||||
|
}
|
||||||
|
streamURI, err := a.rewriteStreamURI(endpoint, streamResponse.Body.Response.MediaURI.URI, credentials)
|
||||||
|
if err != nil {
|
||||||
|
return ProbeResult{}, err
|
||||||
|
}
|
||||||
|
return ProbeResult{
|
||||||
|
Manufacturer: info.Body.Response.Manufacturer,
|
||||||
|
Model: info.Body.Response.Model,
|
||||||
|
FirmwareVersion: info.Body.Response.FirmwareVersion,
|
||||||
|
SerialNumber: info.Body.Response.SerialNumber,
|
||||||
|
Profiles: profiles,
|
||||||
|
StreamURI: streamURI,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HTTPAdapter) SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error {
|
||||||
|
endpoint, credentials, err := a.target(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
utc := value.UTC()
|
||||||
|
body := `<tds:SetSystemDateAndTime xmlns:tds="` + deviceNamespace + `" xmlns:tt="http://www.onvif.org/ver10/schema">` +
|
||||||
|
`<tds:DateTimeType>Manual</tds:DateTimeType><tds:DaylightSavings>false</tds:DaylightSavings>` +
|
||||||
|
`<tds:UTCDateTime><tt:Time><tt:Hour>` + strconv.Itoa(utc.Hour()) + `</tt:Hour><tt:Minute>` + strconv.Itoa(utc.Minute()) +
|
||||||
|
`</tt:Minute><tt:Second>` + strconv.Itoa(utc.Second()) + `</tt:Second></tt:Time><tt:Date><tt:Year>` + strconv.Itoa(utc.Year()) +
|
||||||
|
`</tt:Year><tt:Month>` + strconv.Itoa(int(utc.Month())) + `</tt:Month><tt:Day>` + strconv.Itoa(utc.Day()) +
|
||||||
|
`</tt:Day></tt:Date></tds:UTCDateTime></tds:SetSystemDateAndTime>`
|
||||||
|
_, err = a.call(ctx, endpoint, deviceNamespace+"/SetSystemDateAndTime", body, credentials)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HTTPAdapter) target(target Target) (*url.URL, Credentials, error) {
|
||||||
|
endpoint, err := url.Parse(target.EndpointRef)
|
||||||
|
if err != nil || endpoint.Host == "" || endpoint.User != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
|
||||||
|
return nil, Credentials{}, invalidResponse("invalid ONVIF endpoint")
|
||||||
|
}
|
||||||
|
credentials, err := a.credentials.Resolve(target.CredentialRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, Credentials{}, &Error{Code: ErrorAuthentication, Err: err}
|
||||||
|
}
|
||||||
|
return endpoint, credentials, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HTTPAdapter) call(ctx context.Context, endpoint *url.URL, action, body string, credentials Credentials) ([]byte, error) {
|
||||||
|
nonce := make([]byte, 20)
|
||||||
|
if _, err := io.ReadFull(a.random, nonce); err != nil {
|
||||||
|
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("create authentication nonce")}
|
||||||
|
}
|
||||||
|
created := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
digestInput := append(append(append([]byte{}, nonce...), []byte(created)...), []byte(credentials.ONVIFPassword)...)
|
||||||
|
digest := sha1.Sum(digestInput)
|
||||||
|
envelope := `<?xml version="1.0" encoding="UTF-8"?>` +
|
||||||
|
`<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">` +
|
||||||
|
`<s:Header><wsse:Security s:mustUnderstand="1"><wsse:UsernameToken><wsse:Username>` + escapeXML(credentials.ONVIFUsername) +
|
||||||
|
`</wsse:Username><wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">` +
|
||||||
|
base64.StdEncoding.EncodeToString(digest[:]) + `</wsse:Password><wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">` +
|
||||||
|
base64.StdEncoding.EncodeToString(nonce) + `</wsse:Nonce><wsu:Created>` + created +
|
||||||
|
`</wsu:Created></wsse:UsernameToken></wsse:Security></s:Header><s:Body>` + body + `</s:Body></s:Envelope>`
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(envelope))
|
||||||
|
if err != nil {
|
||||||
|
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("create ONVIF request")}
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", `application/soap+xml; charset=utf-8; action="`+action+`"`)
|
||||||
|
response, err := a.client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
code := ErrorUnavailable
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
code = ErrorTimeout
|
||||||
|
}
|
||||||
|
return nil, &Error{Code: code, Err: fmt.Errorf("ONVIF transport failed")}
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("read ONVIF response")}
|
||||||
|
}
|
||||||
|
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
||||||
|
return nil, &Error{Code: ErrorAuthentication, Err: fmt.Errorf("ONVIF authorization failed")}
|
||||||
|
}
|
||||||
|
if fault := soapFault(responseBody); fault != "" {
|
||||||
|
code := ErrorInvalidReply
|
||||||
|
lower := strings.ToLower(fault)
|
||||||
|
if strings.Contains(lower, "authoriz") || strings.Contains(lower, "notauthorized") {
|
||||||
|
code = ErrorAuthentication
|
||||||
|
}
|
||||||
|
return nil, &Error{Code: code, Err: fmt.Errorf("ONVIF SOAP fault")}
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("ONVIF returned HTTP status %d", response.StatusCode)}
|
||||||
|
}
|
||||||
|
return responseBody, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalMediaEndpoint(deviceEndpoint *url.URL, services []service) (*url.URL, error) {
|
||||||
|
for _, value := range services {
|
||||||
|
if value.Namespace != mediaNamespace || value.XAddr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mediaEndpoint, err := url.Parse(value.XAddr)
|
||||||
|
if err != nil || mediaEndpoint.Host == "" {
|
||||||
|
return nil, invalidResponse("invalid media service address")
|
||||||
|
}
|
||||||
|
mediaEndpoint.Scheme = deviceEndpoint.Scheme
|
||||||
|
mediaEndpoint.Host = deviceEndpoint.Host
|
||||||
|
mediaEndpoint.User = nil
|
||||||
|
return mediaEndpoint, nil
|
||||||
|
}
|
||||||
|
return nil, invalidResponse("media service is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *HTTPAdapter) rewriteStreamURI(deviceEndpoint *url.URL, raw string, credentials Credentials) (string, error) {
|
||||||
|
stream, err := url.Parse(raw)
|
||||||
|
if err != nil || stream.Host == "" || (stream.Scheme != "rtsp" && stream.Scheme != "rtsps") {
|
||||||
|
return "", invalidResponse("invalid stream URI")
|
||||||
|
}
|
||||||
|
host := a.options.RTSPRewriteHost
|
||||||
|
if host == "" {
|
||||||
|
host = deviceEndpoint.Hostname()
|
||||||
|
}
|
||||||
|
port := a.options.RTSPRewritePort
|
||||||
|
if port == 0 {
|
||||||
|
if parsedPort := stream.Port(); parsedPort != "" {
|
||||||
|
value, parseErr := strconv.Atoi(parsedPort)
|
||||||
|
if parseErr != nil {
|
||||||
|
return "", invalidResponse("invalid stream port")
|
||||||
|
}
|
||||||
|
port = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if port > 0 {
|
||||||
|
stream.Host = net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
|
} else {
|
||||||
|
stream.Host = host
|
||||||
|
}
|
||||||
|
stream.User = url.UserPassword(credentials.RTSPUsername, credentials.RTSPPassword)
|
||||||
|
if a.options.StripRTSPQuery {
|
||||||
|
stream.RawQuery = ""
|
||||||
|
stream.ForceQuery = false
|
||||||
|
}
|
||||||
|
return stream.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidResponse(message string) error {
|
||||||
|
return &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("%s", message)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeXML(value string) string {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
_ = xml.EscapeText(&buffer, []byte(value))
|
||||||
|
return buffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func soapFault(body []byte) string {
|
||||||
|
decoder := xml.NewDecoder(bytes.NewReader(body))
|
||||||
|
inFault := false
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch value := token.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
if value.Name.Local == "Fault" {
|
||||||
|
inFault = true
|
||||||
|
}
|
||||||
|
if inFault && (value.Name.Local == "Text" || value.Name.Local == "faultstring") {
|
||||||
|
var message string
|
||||||
|
if decoder.DecodeElement(&message, &value) == nil {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case xml.EndElement:
|
||||||
|
if value.Name.Local == "Fault" {
|
||||||
|
return "SOAP fault"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceInformationEnvelope struct {
|
||||||
|
Body struct {
|
||||||
|
Response struct {
|
||||||
|
Manufacturer string `xml:"Manufacturer"`
|
||||||
|
Model string `xml:"Model"`
|
||||||
|
FirmwareVersion string `xml:"FirmwareVersion"`
|
||||||
|
SerialNumber string `xml:"SerialNumber"`
|
||||||
|
} `xml:"GetDeviceInformationResponse"`
|
||||||
|
} `xml:"Body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type service struct {
|
||||||
|
Namespace string `xml:"Namespace"`
|
||||||
|
XAddr string `xml:"XAddr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type servicesEnvelope struct {
|
||||||
|
Body struct {
|
||||||
|
Response struct {
|
||||||
|
Services []service `xml:"Service"`
|
||||||
|
} `xml:"GetServicesResponse"`
|
||||||
|
} `xml:"Body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type profileResponse struct {
|
||||||
|
Token string `xml:"token,attr"`
|
||||||
|
Name string `xml:"Name"`
|
||||||
|
VideoEncoder *struct{} `xml:"VideoEncoderConfiguration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type profilesEnvelope struct {
|
||||||
|
Body struct {
|
||||||
|
Response struct {
|
||||||
|
Profiles []profileResponse `xml:"Profiles"`
|
||||||
|
} `xml:"GetProfilesResponse"`
|
||||||
|
} `xml:"Body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamURIEnvelope struct {
|
||||||
|
Body struct {
|
||||||
|
Response struct {
|
||||||
|
MediaURI struct {
|
||||||
|
URI string `xml:"Uri"`
|
||||||
|
} `xml:"MediaUri"`
|
||||||
|
} `xml:"GetStreamUriResponse"`
|
||||||
|
} `xml:"Body"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package onvif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type staticCredentials struct {
|
||||||
|
value Credentials
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s staticCredentials) Resolve(string) (Credentials, error) {
|
||||||
|
return s.value, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapterDiscoversMediaAndRewritesNATStream(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if !strings.Contains(request.Header.Get("Content-Type"), "action=") {
|
||||||
|
t.Fatal("SOAP action is required")
|
||||||
|
}
|
||||||
|
body := ""
|
||||||
|
switch {
|
||||||
|
case strings.Contains(request.Header.Get("Content-Type"), "GetDeviceInformation"):
|
||||||
|
body = `<tds:GetDeviceInformationResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl"><tds:Manufacturer>HIKVISION</tds:Manufacturer><tds:Model>camera</tds:Model><tds:FirmwareVersion>v1</tds:FirmwareVersion><tds:SerialNumber>serial</tds:SerialNumber></tds:GetDeviceInformationResponse>`
|
||||||
|
case strings.Contains(request.Header.Get("Content-Type"), "GetServices"):
|
||||||
|
body = `<tds:GetServicesResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl"><tds:Service><tds:Namespace>http://www.onvif.org/ver10/media/wsdl</tds:Namespace><tds:XAddr>http://192.0.2.10/onvif/Media</tds:XAddr></tds:Service></tds:GetServicesResponse>`
|
||||||
|
case strings.Contains(request.Header.Get("Content-Type"), "GetProfiles"):
|
||||||
|
body = `<trt:GetProfilesResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl"><trt:Profiles token="main"><trt:Name>Main</trt:Name><trt:VideoEncoderConfiguration/></trt:Profiles></trt:GetProfilesResponse>`
|
||||||
|
case strings.Contains(request.Header.Get("Content-Type"), "GetStreamUri"):
|
||||||
|
body = `<trt:GetStreamUriResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl"><trt:MediaUri><trt:Uri>rtsp://192.0.2.10:554/Streaming/Channels/101?transportmode=unicast&profile=Profile_1</trt:Uri></trt:MediaUri></trt:GetStreamUriResponse>`
|
||||||
|
default:
|
||||||
|
http.Error(writer, "unexpected action", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.Header().Set("Content-Type", "application/soap+xml")
|
||||||
|
_, _ = fmt.Fprintf(writer, `<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body>%s</s:Body></s:Envelope>`, body)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
credentials := Credentials{
|
||||||
|
ONVIFUsername: "onvif-user", ONVIFPassword: "onvif-password",
|
||||||
|
RTSPUsername: "rtsp-user", RTSPPassword: "rtsp-password",
|
||||||
|
}
|
||||||
|
adapter := NewHTTPAdapter(staticCredentials{value: credentials}, server.Client(), HTTPOptions{
|
||||||
|
RTSPRewriteHost: "127.0.0.1", RTSPRewritePort: 10554, StripRTSPQuery: true,
|
||||||
|
})
|
||||||
|
result, err := adapter.Probe(context.Background(), Target{
|
||||||
|
EndpointRef: server.URL + "/onvif/device_service", CredentialRef: "env://camera",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Manufacturer != "HIKVISION" || result.Model != "camera" || len(result.Profiles) != 1 {
|
||||||
|
t.Fatalf("unexpected probe result: %+v", result)
|
||||||
|
}
|
||||||
|
stream, err := url.Parse(result.StreamURI)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stream.Host != "127.0.0.1:10554" || stream.Path != "/Streaming/Channels/101" || stream.RawQuery != "" {
|
||||||
|
t.Fatalf("unexpected rewritten stream address: host=%s path=%s", stream.Host, stream.Path)
|
||||||
|
}
|
||||||
|
password, _ := stream.User.Password()
|
||||||
|
if stream.User.Username() != credentials.RTSPUsername || password != credentials.RTSPPassword {
|
||||||
|
t.Fatal("RTSP credentials were not injected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapterPreservesRTSPQueryByDefault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
adapter := NewHTTPAdapter(staticCredentials{}, nil, HTTPOptions{})
|
||||||
|
streamURI, err := adapter.rewriteStreamURI(
|
||||||
|
&url.URL{Scheme: "http", Host: "camera.example:8008"},
|
||||||
|
"rtsp://192.0.2.10:554/live?profile=main",
|
||||||
|
Credentials{RTSPUsername: "user", RTSPPassword: "secret"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stream, err := url.Parse(streamURI)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stream.RawQuery != "profile=main" {
|
||||||
|
t.Fatalf("RTSP query was unexpectedly changed: %q", stream.RawQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapterMapsAuthorizationFaultWithoutLeakingSecret(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = writer.Write([]byte(`<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><s:Fault><s:Reason><s:Text>The action requires authorization</s:Text></s:Reason></s:Fault></s:Body></s:Envelope>`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
secret := "not-for-errors"
|
||||||
|
adapter := NewHTTPAdapter(staticCredentials{value: Credentials{
|
||||||
|
ONVIFUsername: "user", ONVIFPassword: secret, RTSPUsername: "user", RTSPPassword: secret,
|
||||||
|
}}, server.Client(), HTTPOptions{})
|
||||||
|
_, err := adapter.Probe(context.Background(), Target{EndpointRef: server.URL, CredentialRef: "env://camera"})
|
||||||
|
if CodeOf(err) != ErrorAuthentication || strings.Contains(err.Error(), secret) {
|
||||||
|
t.Fatalf("expected redacted authentication error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvCredentialsAndDirectRTSPRouting(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
values := map[string]string{
|
||||||
|
"SENSE_CREDENTIAL_CAMERA_ONVIF_USERNAME": "onvif",
|
||||||
|
"SENSE_CREDENTIAL_CAMERA_ONVIF_PASSWORD": "onvif-secret",
|
||||||
|
"SENSE_CREDENTIAL_CAMERA_RTSP_USERNAME": "rtsp",
|
||||||
|
"SENSE_CREDENTIAL_CAMERA_RTSP_PASSWORD": "rtsp-secret",
|
||||||
|
}
|
||||||
|
provider := EnvCredentials{LookupEnv: func(name string) (string, bool) {
|
||||||
|
value, ok := values[name]
|
||||||
|
return value, ok
|
||||||
|
}}
|
||||||
|
resolved, err := provider.Resolve("env://camera")
|
||||||
|
if err != nil || resolved.RTSPUsername != "rtsp" {
|
||||||
|
t.Fatalf("resolve credentials: %+v err=%v", resolved, err)
|
||||||
|
}
|
||||||
|
router := NewRouter(UnavailableAdapter{}, provider)
|
||||||
|
result, err := router.Probe(context.Background(), Target{
|
||||||
|
EndpointRef: "rtsp://127.0.0.1:8555/synthetic-1",
|
||||||
|
})
|
||||||
|
if err != nil || result.StreamURI != "rtsp://127.0.0.1:8555/synthetic-1" {
|
||||||
|
t.Fatalf("route direct RTSP: %+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Package onvif defines the ONVIF boundary used by Sense.
|
||||||
|
package onvif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Target struct {
|
||||||
|
// EndpointRef identifies a device endpoint but must not contain credentials.
|
||||||
|
EndpointRef string
|
||||||
|
// CredentialRef is an opaque secret-store reference, never a password.
|
||||||
|
CredentialRef string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Profile struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
VideoEncoder bool `json:"video_encoder"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProbeResult struct {
|
||||||
|
Manufacturer string `json:"manufacturer"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
FirmwareVersion string `json:"firmware_version"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
Profiles []Profile `json:"profiles"`
|
||||||
|
StreamURI string `json:"stream_uri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Adapter interface {
|
||||||
|
Probe(ctx context.Context, target Target) (ProbeResult, error)
|
||||||
|
SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorCode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrorAuthentication ErrorCode = "authentication_failed"
|
||||||
|
ErrorTimeout ErrorCode = "timeout"
|
||||||
|
ErrorUnavailable ErrorCode = "unavailable"
|
||||||
|
ErrorInvalidReply ErrorCode = "invalid_response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Code ErrorCode
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
if e.Err == nil {
|
||||||
|
return string(e.Code)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: %v", e.Code, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
func CodeOf(err error) ErrorCode {
|
||||||
|
var onvifError *Error
|
||||||
|
if errors.As(err, &onvifError) {
|
||||||
|
return onvifError.Code
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return ErrorTimeout
|
||||||
|
}
|
||||||
|
return ErrorUnavailable
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package onvif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Router struct {
|
||||||
|
camera Adapter
|
||||||
|
credentials CredentialProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(camera Adapter, credentials CredentialProvider) *Router {
|
||||||
|
return &Router{camera: camera, credentials: credentials}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) Probe(ctx context.Context, target Target) (ProbeResult, error) {
|
||||||
|
endpoint, err := url.Parse(target.EndpointRef)
|
||||||
|
if err != nil || endpoint.Host == "" || endpoint.User != nil {
|
||||||
|
return ProbeResult{}, &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("endpoint reference is invalid")}
|
||||||
|
}
|
||||||
|
switch endpoint.Scheme {
|
||||||
|
case "http", "https":
|
||||||
|
return r.camera.Probe(ctx, target)
|
||||||
|
case "rtsp", "rtsps":
|
||||||
|
if target.CredentialRef != "" {
|
||||||
|
credentials, resolveErr := r.credentials.Resolve(target.CredentialRef)
|
||||||
|
if resolveErr != nil {
|
||||||
|
return ProbeResult{}, &Error{Code: ErrorAuthentication, Err: resolveErr}
|
||||||
|
}
|
||||||
|
endpoint.User = url.UserPassword(credentials.RTSPUsername, credentials.RTSPPassword)
|
||||||
|
}
|
||||||
|
return ProbeResult{
|
||||||
|
Profiles: []Profile{{Token: "direct", Name: "direct", VideoEncoder: true}},
|
||||||
|
StreamURI: endpoint.String(),
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return ProbeResult{}, &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("unsupported endpoint scheme")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error {
|
||||||
|
endpoint, err := url.Parse(target.EndpointRef)
|
||||||
|
if err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
|
||||||
|
return &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("clock sync requires an ONVIF endpoint")}
|
||||||
|
}
|
||||||
|
return r.camera.SetSystemDateAndTime(ctx, target, value)
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"scenarios": {
|
||||||
|
"onvif://camera-ok": {
|
||||||
|
"result": {
|
||||||
|
"manufacturer": "YoVision Fixture",
|
||||||
|
"model": "Offline Camera",
|
||||||
|
"firmware_version": "0.0-fixture",
|
||||||
|
"serial_number": "REDACTED-001",
|
||||||
|
"profiles": [
|
||||||
|
{"token": "main", "name": "Main stream", "video_encoder": true},
|
||||||
|
{"token": "sub", "name": "Sub stream", "video_encoder": true}
|
||||||
|
],
|
||||||
|
"stream_uri": "rtsp://media.invalid/camera-ok"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"onvif://camera-auth": {
|
||||||
|
"probe_error": "authentication_failed",
|
||||||
|
"result": {}
|
||||||
|
},
|
||||||
|
"onvif://camera-slow": {
|
||||||
|
"delay_millis": 100,
|
||||||
|
"result": {
|
||||||
|
"profiles": [{"token": "main", "name": "Main stream", "video_encoder": true}],
|
||||||
|
"stream_uri": "rtsp://media.invalid/camera-slow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package onvif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnavailableAdapter keeps the process boundary explicit until T-006 supplies
|
||||||
|
// a real, whitelist-validated ONVIF adapter. It must not be mistaken for a
|
||||||
|
// compatibility implementation.
|
||||||
|
type UnavailableAdapter struct{}
|
||||||
|
|
||||||
|
func (UnavailableAdapter) Probe(context.Context, Target) (ProbeResult, error) {
|
||||||
|
return ProbeResult{}, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("real ONVIF adapter is not configured")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnavailableAdapter) SetSystemDateAndTime(context.Context, Target, time.Time) error {
|
||||||
|
return &Error{Code: ErrorUnavailable, Err: fmt.Errorf("real ONVIF adapter is not configured")}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
// Package orphan reports MediaMTX configuration paths that do not match the
|
||||||
|
// current Sense ledger. Only paths with durable Sense ownership evidence can
|
||||||
|
// ever enter the controlled cleanup set.
|
||||||
|
package orphan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/metrics"
|
||||||
|
"yovision/sense/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultLeaseDuration = 30 * time.Second
|
||||||
|
defaultOperationTimeout = 20 * time.Second
|
||||||
|
reportTTL = 15 * time.Minute
|
||||||
|
maxCleanupItems = 128
|
||||||
|
crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrLeaseHeld = errors.New("orphan operation lease held")
|
||||||
|
ErrConfirmation = errors.New("orphan cleanup confirmation invalid")
|
||||||
|
ErrSnapshotExpired = errors.New("orphan scan snapshot expired")
|
||||||
|
ErrSafetyBlocked = errors.New("orphan cleanup safety gate blocked")
|
||||||
|
operatorIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Runtime interface {
|
||||||
|
ListPathNames(context.Context) ([]string, error)
|
||||||
|
DeletePath(context.Context, string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
repository store.OrphanRepository
|
||||||
|
runtime Runtime
|
||||||
|
instanceID string
|
||||||
|
metrics *metrics.Registry
|
||||||
|
now func() time.Time
|
||||||
|
leaseDuration time.Duration
|
||||||
|
operationTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type CleanupResult struct {
|
||||||
|
ScanID string `json:"scan_id"`
|
||||||
|
Deleted int `json:"deleted"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(
|
||||||
|
repository store.OrphanRepository,
|
||||||
|
runtime Runtime,
|
||||||
|
instanceID string,
|
||||||
|
registry *metrics.Registry,
|
||||||
|
) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
repository: repository, runtime: runtime, instanceID: instanceID, metrics: registry,
|
||||||
|
now: time.Now, leaseDuration: defaultLeaseDuration, operationTimeout: defaultOperationTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Report(ctx context.Context) (store.OrphanScan, error) {
|
||||||
|
now := m.now().UTC()
|
||||||
|
token, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return store.OrphanScan{}, err
|
||||||
|
}
|
||||||
|
acquired, err := m.repository.AcquireOperationalLease(
|
||||||
|
ctx, store.OperationalLeaseOrphanScan, m.instanceID, token, now, m.leaseDuration,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
m.observeScan(0, 0, 0, err)
|
||||||
|
return store.OrphanScan{}, err
|
||||||
|
}
|
||||||
|
if !acquired {
|
||||||
|
return store.OrphanScan{}, ErrLeaseHeld
|
||||||
|
}
|
||||||
|
saved := false
|
||||||
|
defer func() {
|
||||||
|
if saved {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
releaseCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = m.repository.ReleaseOperationalLease(
|
||||||
|
releaseCtx, store.OperationalLeaseOrphanScan, m.instanceID, token, m.now().UTC(),
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
operationCtx, cancel := context.WithTimeout(ctx, m.operationTimeout)
|
||||||
|
paths, err := m.runtime.ListPathNames(operationCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
m.observeScan(0, 0, 0, err)
|
||||||
|
return store.OrphanScan{}, fmt.Errorf("list MediaMTX paths: %w", err)
|
||||||
|
}
|
||||||
|
ownership, err := m.repository.ListMediaPathOwnership(ctx)
|
||||||
|
if err != nil {
|
||||||
|
m.observeScan(0, 0, 0, err)
|
||||||
|
return store.OrphanScan{}, err
|
||||||
|
}
|
||||||
|
findings, stale, unowned := classify(paths, ownership)
|
||||||
|
allowed, reason := safetyGate(stale, len(paths))
|
||||||
|
completedAt := m.now().UTC()
|
||||||
|
acquired, err = m.repository.AcquireOperationalLease(
|
||||||
|
ctx, store.OperationalLeaseOrphanScan, m.instanceID, token, completedAt, m.leaseDuration,
|
||||||
|
)
|
||||||
|
if err != nil || !acquired {
|
||||||
|
if err == nil {
|
||||||
|
err = store.ErrOperationalLeaseLost
|
||||||
|
}
|
||||||
|
m.observeScan(len(paths), stale, unowned, err)
|
||||||
|
return store.OrphanScan{}, err
|
||||||
|
}
|
||||||
|
id, err := newScanID(completedAt)
|
||||||
|
if err != nil {
|
||||||
|
m.observeScan(0, 0, 0, err)
|
||||||
|
return store.OrphanScan{}, err
|
||||||
|
}
|
||||||
|
scan := store.OrphanScan{
|
||||||
|
ID: id, InstanceID: m.instanceID, ObservedCount: len(paths),
|
||||||
|
OwnedStaleCount: stale, UnownedCount: unowned,
|
||||||
|
SafetyAllowed: allowed, SafetyReason: reason,
|
||||||
|
CompletedAt: completedAt, ExpiresAt: completedAt.Add(reportTTL), Findings: findings,
|
||||||
|
}
|
||||||
|
if err := m.repository.SaveOrphanScan(ctx, scan, m.instanceID, token); err != nil {
|
||||||
|
m.observeScan(len(paths), stale, unowned, err)
|
||||||
|
return store.OrphanScan{}, err
|
||||||
|
}
|
||||||
|
saved = true
|
||||||
|
m.observeScan(len(paths), stale, unowned, nil)
|
||||||
|
return scan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func classify(
|
||||||
|
paths []string,
|
||||||
|
ownership []store.MediaPathOwnership,
|
||||||
|
) ([]store.OrphanFinding, int, int) {
|
||||||
|
history := make(map[string]store.MediaPathOwnership, len(ownership))
|
||||||
|
for _, value := range ownership {
|
||||||
|
history[value.PathName] = value
|
||||||
|
}
|
||||||
|
unique := make(map[string]struct{}, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
unique[path] = struct{}{}
|
||||||
|
}
|
||||||
|
ordered := make([]string, 0, len(unique))
|
||||||
|
for path := range unique {
|
||||||
|
ordered = append(ordered, path)
|
||||||
|
}
|
||||||
|
sort.Strings(ordered)
|
||||||
|
findings := make([]store.OrphanFinding, 0)
|
||||||
|
stale, unowned := 0, 0
|
||||||
|
for _, path := range ordered {
|
||||||
|
value, known := history[path]
|
||||||
|
switch {
|
||||||
|
case known && value.CurrentClaim:
|
||||||
|
continue
|
||||||
|
case known:
|
||||||
|
stale++
|
||||||
|
findings = append(findings, store.OrphanFinding{
|
||||||
|
PathName: path, Classification: store.OrphanOwnedStale, DeviceID: value.DeviceID,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
unowned++
|
||||||
|
findings = append(findings, store.OrphanFinding{
|
||||||
|
PathName: path, Classification: store.OrphanUnowned,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findings, stale, unowned
|
||||||
|
}
|
||||||
|
|
||||||
|
func safetyGate(candidates, observed int) (bool, string) {
|
||||||
|
switch {
|
||||||
|
case candidates <= 0:
|
||||||
|
return false, "no_candidates"
|
||||||
|
case observed <= 0:
|
||||||
|
return false, "empty_inventory"
|
||||||
|
case candidates > maxCleanupItems:
|
||||||
|
return false, "scope_exceeded"
|
||||||
|
case candidates*100 > observed*10:
|
||||||
|
return false, "ratio_exceeded"
|
||||||
|
default:
|
||||||
|
return true, "allowed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Apply(
|
||||||
|
ctx context.Context,
|
||||||
|
scanID, actorID, confirmation string,
|
||||||
|
) (CleanupResult, error) {
|
||||||
|
result := CleanupResult{ScanID: scanID}
|
||||||
|
if !operatorIDPattern.MatchString(actorID) || confirmation != "DELETE "+scanID {
|
||||||
|
m.blocked("scope_changed")
|
||||||
|
return result, ErrConfirmation
|
||||||
|
}
|
||||||
|
now := m.now().UTC()
|
||||||
|
token, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
acquired, err := m.repository.AcquireOperationalLease(
|
||||||
|
ctx, store.OperationalLeaseOrphanCleanup, m.instanceID, token, now, m.leaseDuration,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if !acquired {
|
||||||
|
return result, ErrLeaseHeld
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
releaseCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = m.repository.ReleaseOperationalLease(
|
||||||
|
releaseCtx, store.OperationalLeaseOrphanCleanup, m.instanceID, token, m.now().UTC(),
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
scan, err := m.repository.GetOrphanScan(ctx, scanID)
|
||||||
|
if err != nil {
|
||||||
|
m.blocked("scope_changed")
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if !scan.ExpiresAt.After(now) {
|
||||||
|
m.blocked("snapshot_expired")
|
||||||
|
return result, ErrSnapshotExpired
|
||||||
|
}
|
||||||
|
if !scan.SafetyAllowed {
|
||||||
|
m.blocked(scan.SafetyReason)
|
||||||
|
return result, fmt.Errorf("%w: %s", ErrSafetyBlocked, scan.SafetyReason)
|
||||||
|
}
|
||||||
|
operationCtx, cancel := context.WithTimeout(ctx, m.operationTimeout)
|
||||||
|
paths, err := m.runtime.ListPathNames(operationCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("refresh MediaMTX path inventory: %w", err)
|
||||||
|
}
|
||||||
|
ownership, err := m.repository.ListMediaPathOwnership(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
candidates := cleanupCandidates(scan, paths, ownership)
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
allowed, reason := safetyGate(len(candidates), len(uniqueNames(paths)))
|
||||||
|
if !allowed {
|
||||||
|
m.blocked(reason)
|
||||||
|
return result, fmt.Errorf("%w: %s", ErrSafetyBlocked, reason)
|
||||||
|
}
|
||||||
|
var failures []error
|
||||||
|
for _, path := range candidates {
|
||||||
|
now = m.now().UTC()
|
||||||
|
acquired, err = m.repository.AcquireOperationalLease(
|
||||||
|
ctx, store.OperationalLeaseOrphanCleanup, m.instanceID, token, now, m.leaseDuration,
|
||||||
|
)
|
||||||
|
if err != nil || !acquired {
|
||||||
|
if err == nil {
|
||||||
|
err = ErrLeaseHeld
|
||||||
|
}
|
||||||
|
failures = append(failures, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
itemCtx, itemCancel := context.WithTimeout(ctx, m.operationTimeout)
|
||||||
|
deleteErr := m.runtime.DeletePath(itemCtx, path)
|
||||||
|
itemCancel()
|
||||||
|
status, code := "deleted", ""
|
||||||
|
if deleteErr != nil {
|
||||||
|
status, code = "failed", "media_error"
|
||||||
|
result.Failed++
|
||||||
|
failures = append(failures, fmt.Errorf("delete owned stale path: %w", deleteErr))
|
||||||
|
} else {
|
||||||
|
result.Deleted++
|
||||||
|
}
|
||||||
|
if err := m.repository.RecordOrphanCleanup(
|
||||||
|
ctx, scanID, path, actorID, status, code, m.now().UTC(),
|
||||||
|
); err != nil {
|
||||||
|
failures = append(failures, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.metrics != nil {
|
||||||
|
m.metrics.ObserveOrphanCleanup(result.Deleted, result.Failed)
|
||||||
|
}
|
||||||
|
return result, errors.Join(failures...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupCandidates(
|
||||||
|
scan store.OrphanScan,
|
||||||
|
paths []string,
|
||||||
|
ownership []store.MediaPathOwnership,
|
||||||
|
) []string {
|
||||||
|
runtime := uniqueNames(paths)
|
||||||
|
history := make(map[string]store.MediaPathOwnership, len(ownership))
|
||||||
|
for _, value := range ownership {
|
||||||
|
history[value.PathName] = value
|
||||||
|
}
|
||||||
|
values := make([]string, 0)
|
||||||
|
for _, finding := range scan.Findings {
|
||||||
|
if finding.Classification != store.OrphanOwnedStale || finding.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, present := runtime[finding.PathName]
|
||||||
|
owner, known := history[finding.PathName]
|
||||||
|
if present && known && !owner.CurrentClaim && owner.DeviceID == finding.DeviceID {
|
||||||
|
values = append(values, finding.PathName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(values)
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueNames(values []string) map[string]struct{} {
|
||||||
|
result := make(map[string]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
result[value] = struct{}{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Run(ctx context.Context, interval time.Duration, report func(error)) {
|
||||||
|
run := func() {
|
||||||
|
_, err := m.Report(ctx)
|
||||||
|
if err != nil && !errors.Is(err, ErrLeaseHeld) && ctx.Err() == nil && report != nil {
|
||||||
|
report(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) observeScan(observed, stale, unowned int, err error) {
|
||||||
|
if m.metrics != nil {
|
||||||
|
m.metrics.ObserveOrphanScan(observed, stale, unowned, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) blocked(reason string) {
|
||||||
|
if m.metrics != nil {
|
||||||
|
m.metrics.ObserveOrphanCleanupBlocked(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken() (string, error) {
|
||||||
|
value := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(value); err != nil {
|
||||||
|
return "", errors.New("generate fencing token")
|
||||||
|
}
|
||||||
|
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
result := make([]byte, 22)
|
||||||
|
number := new(big.Int).SetBytes(value)
|
||||||
|
base := big.NewInt(int64(len(alphabet)))
|
||||||
|
remainder := new(big.Int)
|
||||||
|
for index := len(result) - 1; index >= 0; index-- {
|
||||||
|
number.QuoRem(number, base, remainder)
|
||||||
|
result[index] = alphabet[remainder.Int64()]
|
||||||
|
}
|
||||||
|
return string(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newScanID(now time.Time) (string, error) {
|
||||||
|
value := make([]byte, 16)
|
||||||
|
milliseconds := uint64(now.UTC().UnixMilli())
|
||||||
|
value[0], value[1], value[2] = byte(milliseconds>>40), byte(milliseconds>>32), byte(milliseconds>>24)
|
||||||
|
value[3], value[4], value[5] = byte(milliseconds>>16), byte(milliseconds>>8), byte(milliseconds)
|
||||||
|
if _, err := rand.Read(value[6:]); err != nil {
|
||||||
|
return "", errors.New("generate orphan scan identifier")
|
||||||
|
}
|
||||||
|
number := new(big.Int).SetBytes(value)
|
||||||
|
base := big.NewInt(32)
|
||||||
|
remainder := new(big.Int)
|
||||||
|
encoded := make([]byte, 26)
|
||||||
|
for index := len(encoded) - 1; index >= 0; index-- {
|
||||||
|
number.QuoRem(number, base, remainder)
|
||||||
|
encoded[index] = crockford[remainder.Int64()]
|
||||||
|
}
|
||||||
|
return "scan_" + string(encoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Confirmation(scanID string) string {
|
||||||
|
return strings.Join([]string{"DELETE", scanID}, " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package orphan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeLease struct {
|
||||||
|
owner, token string
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRepository struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
leases map[string]fakeLease
|
||||||
|
ownership []store.MediaPathOwnership
|
||||||
|
scans map[string]store.OrphanScan
|
||||||
|
deleted map[string]map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeRepository() *fakeRepository {
|
||||||
|
return &fakeRepository{
|
||||||
|
leases: make(map[string]fakeLease), scans: make(map[string]store.OrphanScan),
|
||||||
|
deleted: make(map[string]map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepository) AcquireOperationalLease(
|
||||||
|
_ context.Context,
|
||||||
|
name, owner, token string,
|
||||||
|
now time.Time,
|
||||||
|
duration time.Duration,
|
||||||
|
) (bool, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
current, exists := f.leases[name]
|
||||||
|
if exists && current.expires.After(now) && (current.owner != owner || current.token != token) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
f.leases[name] = fakeLease{owner: owner, token: token, expires: now.Add(duration)}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepository) ReleaseOperationalLease(
|
||||||
|
_ context.Context,
|
||||||
|
name, owner, token string,
|
||||||
|
now time.Time,
|
||||||
|
) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
current := f.leases[name]
|
||||||
|
if current.owner == owner && current.token == token {
|
||||||
|
current.expires = now
|
||||||
|
f.leases[name] = current
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepository) ListMediaPathOwnership(context.Context) ([]store.MediaPathOwnership, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]store.MediaPathOwnership(nil), f.ownership...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepository) SaveOrphanScan(
|
||||||
|
_ context.Context,
|
||||||
|
scan store.OrphanScan,
|
||||||
|
owner, token string,
|
||||||
|
) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
lease := f.leases[store.OperationalLeaseOrphanScan]
|
||||||
|
if lease.owner != owner || lease.token != token || !lease.expires.After(scan.CompletedAt) {
|
||||||
|
return store.ErrOperationalLeaseLost
|
||||||
|
}
|
||||||
|
f.scans[scan.ID] = cloneScan(scan)
|
||||||
|
lease.expires = scan.CompletedAt
|
||||||
|
f.leases[store.OperationalLeaseOrphanScan] = lease
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepository) GetOrphanScan(_ context.Context, id string) (store.OrphanScan, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
scan, exists := f.scans[id]
|
||||||
|
if !exists {
|
||||||
|
return store.OrphanScan{}, store.ErrOrphanScanNotFound
|
||||||
|
}
|
||||||
|
result := cloneScan(scan)
|
||||||
|
for index := range result.Findings {
|
||||||
|
result.Findings[index].Deleted = f.deleted[id][result.Findings[index].PathName]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepository) RecordOrphanCleanup(
|
||||||
|
_ context.Context,
|
||||||
|
scanID, pathName, _, status, _ string,
|
||||||
|
_ time.Time,
|
||||||
|
) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.deleted[scanID] == nil {
|
||||||
|
f.deleted[scanID] = make(map[string]bool)
|
||||||
|
}
|
||||||
|
if status == "deleted" {
|
||||||
|
f.deleted[scanID][pathName] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneScan(value store.OrphanScan) store.OrphanScan {
|
||||||
|
value.Findings = append([]store.OrphanFinding(nil), value.Findings...)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRuntime struct {
|
||||||
|
paths map[string]bool
|
||||||
|
fail map[string]bool
|
||||||
|
deleted []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRuntime) ListPathNames(context.Context) ([]string, error) {
|
||||||
|
values := make([]string, 0, len(f.paths))
|
||||||
|
for path, present := range f.paths {
|
||||||
|
if present {
|
||||||
|
values = append(values, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(values)
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRuntime) DeletePath(_ context.Context, path string) error {
|
||||||
|
if f.fail[path] {
|
||||||
|
return errors.New("redacted media failure")
|
||||||
|
}
|
||||||
|
delete(f.paths, path)
|
||||||
|
f.deleted = append(f.deleted, path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafetyGateUsesExactTenPercentBoundary(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if allowed, reason := safetyGate(1, 10); !allowed || reason != "allowed" {
|
||||||
|
t.Fatalf("exact 10%% boundary was rejected: %v %s", allowed, reason)
|
||||||
|
}
|
||||||
|
if allowed, reason := safetyGate(1, 9); allowed || reason != "ratio_exceeded" {
|
||||||
|
t.Fatalf("more than 10%% was accepted: %v %s", allowed, reason)
|
||||||
|
}
|
||||||
|
if allowed, reason := safetyGate(129, 2000); allowed || reason != "scope_exceeded" {
|
||||||
|
t.Fatalf("129-item scope was accepted: %v %s", allowed, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportClassifiesOwnershipAndApplyNeverDeletesUnowned(t *testing.T) {
|
||||||
|
repository := newFakeRepository()
|
||||||
|
repository.ownership = []store.MediaPathOwnership{
|
||||||
|
{PathName: "owned-stale", DeviceID: "old-device"},
|
||||||
|
{PathName: "owned-current", DeviceID: "live-device", CurrentClaim: true},
|
||||||
|
}
|
||||||
|
runtime := &fakeRuntime{paths: map[string]bool{
|
||||||
|
"owned-stale": true, "owned-current": true,
|
||||||
|
"unowned-1": true, "unowned-2": true, "unowned-3": true, "unowned-4": true,
|
||||||
|
"unowned-5": true, "unowned-6": true, "unowned-7": true, "unowned-8": true,
|
||||||
|
}, fail: make(map[string]bool)}
|
||||||
|
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||||
|
manager := New(repository, runtime, "ins_test", nil)
|
||||||
|
manager.now = func() time.Time { return now }
|
||||||
|
scan, err := manager.Report(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if scan.ObservedCount != 10 || scan.OwnedStaleCount != 1 || scan.UnownedCount != 8 || !scan.SafetyAllowed {
|
||||||
|
t.Fatalf("unexpected report: %+v", scan)
|
||||||
|
}
|
||||||
|
result, err := manager.Apply(
|
||||||
|
context.Background(), scan.ID, "operator-1", Confirmation(scan.ID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Deleted != 1 || len(runtime.deleted) != 1 || runtime.deleted[0] != "owned-stale" {
|
||||||
|
t.Fatalf("cleanup escaped owned stale set: result=%+v deleted=%v", result, runtime.deleted)
|
||||||
|
}
|
||||||
|
for _, path := range []string{"owned-current", "unowned-1", "unowned-8"} {
|
||||||
|
if !runtime.paths[path] {
|
||||||
|
t.Fatalf("cleanup deleted protected path %q", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyBlocksExpiredOrOversizedSnapshotBeforeDelete(t *testing.T) {
|
||||||
|
repository := newFakeRepository()
|
||||||
|
repository.ownership = []store.MediaPathOwnership{{PathName: "stale", DeviceID: "old"}}
|
||||||
|
runtime := &fakeRuntime{paths: map[string]bool{"stale": true}, fail: make(map[string]bool)}
|
||||||
|
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||||
|
manager := New(repository, runtime, "ins_test", nil)
|
||||||
|
manager.now = func() time.Time { return now }
|
||||||
|
scan, err := manager.Report(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if scan.SafetyAllowed || scan.SafetyReason != "ratio_exceeded" {
|
||||||
|
t.Fatalf("single-path 100%% cleanup was not blocked: %+v", scan)
|
||||||
|
}
|
||||||
|
if _, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID)); !errors.Is(err, ErrSafetyBlocked) {
|
||||||
|
t.Fatalf("expected ratio gate, got %v", err)
|
||||||
|
}
|
||||||
|
if len(runtime.deleted) != 0 {
|
||||||
|
t.Fatal("ratio-blocked cleanup mutated MediaMTX")
|
||||||
|
}
|
||||||
|
|
||||||
|
for index := 0; index < 9; index++ {
|
||||||
|
runtime.paths[string(rune('a'+index))] = true
|
||||||
|
}
|
||||||
|
scan, err = manager.Report(context.Background())
|
||||||
|
if err != nil || !scan.SafetyAllowed {
|
||||||
|
t.Fatalf("expected a fresh 1/10 executable report: %+v %v", scan, err)
|
||||||
|
}
|
||||||
|
now = now.Add(reportTTL)
|
||||||
|
if _, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID)); !errors.Is(err, ErrSnapshotExpired) {
|
||||||
|
t.Fatalf("expected expired snapshot, got %v", err)
|
||||||
|
}
|
||||||
|
if len(runtime.deleted) != 0 {
|
||||||
|
t.Fatal("expired cleanup mutated MediaMTX")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyIsRetryableAndCannotExpandBeyondSnapshot(t *testing.T) {
|
||||||
|
repository := newFakeRepository()
|
||||||
|
repository.ownership = []store.MediaPathOwnership{
|
||||||
|
{PathName: "stale-a", DeviceID: "old-a"},
|
||||||
|
{PathName: "stale-b", DeviceID: "old-b"},
|
||||||
|
}
|
||||||
|
runtime := &fakeRuntime{paths: make(map[string]bool), fail: map[string]bool{"stale-b": true}}
|
||||||
|
runtime.paths["stale-a"], runtime.paths["stale-b"] = true, true
|
||||||
|
for index := 0; index < 18; index++ {
|
||||||
|
runtime.paths[string(rune(0x100+index))] = true
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||||
|
manager := New(repository, runtime, "ins_test", nil)
|
||||||
|
manager.now = func() time.Time { return now }
|
||||||
|
scan, err := manager.Report(context.Background())
|
||||||
|
if err != nil || !scan.SafetyAllowed {
|
||||||
|
t.Fatalf("expected 2/20 report: %+v %v", scan, err)
|
||||||
|
}
|
||||||
|
repository.ownership = append(repository.ownership,
|
||||||
|
store.MediaPathOwnership{PathName: "new-stale", DeviceID: "new-old"})
|
||||||
|
runtime.paths["new-stale"] = true
|
||||||
|
result, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID))
|
||||||
|
if err == nil || result.Deleted != 1 || result.Failed != 1 {
|
||||||
|
t.Fatalf("expected one partial failure: %+v %v", result, err)
|
||||||
|
}
|
||||||
|
if !runtime.paths["new-stale"] {
|
||||||
|
t.Fatal("cleanup expanded beyond the approved snapshot")
|
||||||
|
}
|
||||||
|
runtime.fail["stale-b"] = false
|
||||||
|
result, err = manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID))
|
||||||
|
if err != nil || result.Deleted != 1 || result.Failed != 0 {
|
||||||
|
t.Fatalf("failed item was not retryable: %+v %v", result, err)
|
||||||
|
}
|
||||||
|
if !runtime.paths["new-stale"] {
|
||||||
|
t.Fatal("retry expanded beyond the approved snapshot")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Package probe maps MediaMTX runtime path health into the Sense actual state.
|
||||||
|
package probe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultBatchSize = 128
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error)
|
||||||
|
UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error
|
||||||
|
RequestReconcile(ctx context.Context, id string, now time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimePaths interface {
|
||||||
|
PathReady(ctx context.Context, name string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Checker struct {
|
||||||
|
repository Repository
|
||||||
|
media RuntimePaths
|
||||||
|
now func() time.Time
|
||||||
|
batchSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repository Repository, media RuntimePaths) *Checker {
|
||||||
|
return &Checker{repository: repository, media: media, now: time.Now, batchSize: defaultBatchSize}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Checker) RunOnce(ctx context.Context) error {
|
||||||
|
devices, err := c.repository.ListEnabledVideoDevices(ctx, c.batchSize)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list probe candidates: %w", err)
|
||||||
|
}
|
||||||
|
var runErrors []error
|
||||||
|
for _, value := range devices {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ready, probeErr := c.media.PathReady(ctx, value.PathName)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
state := device.ActualOffline
|
||||||
|
if probeErr == nil && ready {
|
||||||
|
state = device.ActualOnline
|
||||||
|
}
|
||||||
|
now := c.now().UTC()
|
||||||
|
if probeErr != nil {
|
||||||
|
if requestErr := c.repository.RequestReconcile(ctx, value.ID, now); requestErr != nil {
|
||||||
|
runErrors = append(runErrors, fmt.Errorf("request device %s reconciliation: %w", value.ID, requestErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if updateErr := c.repository.UpdateActualState(ctx, value.ID, state, now); updateErr != nil {
|
||||||
|
runErrors = append(runErrors, fmt.Errorf("update device %s health: %w", value.ID, updateErr))
|
||||||
|
}
|
||||||
|
if probeErr != nil {
|
||||||
|
runErrors = append(runErrors, fmt.Errorf("probe device %s: %w", value.ID, probeErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(runErrors...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Checker) Run(ctx context.Context, interval time.Duration, report func(error)) {
|
||||||
|
if err := c.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
|
||||||
|
report(err)
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
|
||||||
|
report(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package probe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRepository struct {
|
||||||
|
devices []device.Device
|
||||||
|
states map[string]device.ActualState
|
||||||
|
requested map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepository) RequestReconcile(_ context.Context, id string, _ time.Time) error {
|
||||||
|
if f.requested == nil {
|
||||||
|
f.requested = make(map[string]int)
|
||||||
|
}
|
||||||
|
f.requested[id]++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepository) ListEnabledVideoDevices(context.Context, int) ([]device.Device, error) {
|
||||||
|
return f.devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepository) UpdateActualState(_ context.Context, id string, state device.ActualState, _ time.Time) error {
|
||||||
|
if f.states == nil {
|
||||||
|
f.states = make(map[string]device.ActualState)
|
||||||
|
}
|
||||||
|
f.states[id] = state
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRuntime struct {
|
||||||
|
ready map[string]bool
|
||||||
|
errors map[string]error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeRuntime) PathReady(_ context.Context, name string) (bool, error) {
|
||||||
|
return f.ready[name], f.errors[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckerMapsReadyAndUnavailablePaths(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
repository := &fakeRepository{devices: []device.Device{
|
||||||
|
{ID: "online", PathName: "online"}, {ID: "offline", PathName: "offline"},
|
||||||
|
}}
|
||||||
|
checker := New(repository, fakeRuntime{
|
||||||
|
ready: map[string]bool{"online": true}, errors: map[string]error{"offline": errors.New("unavailable")},
|
||||||
|
})
|
||||||
|
err := checker.RunOnce(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("probe transport error must remain observable")
|
||||||
|
}
|
||||||
|
if repository.states["online"] != device.ActualOnline || repository.states["offline"] != device.ActualOffline {
|
||||||
|
t.Fatalf("unexpected actual states: %+v", repository.states)
|
||||||
|
}
|
||||||
|
if repository.requested["offline"] != 1 || repository.requested["online"] != 0 {
|
||||||
|
t.Fatalf("unexpected reconcile requests: %+v", repository.requested)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// Package reconcile converges MediaMTX paths from the database desired state.
|
||||||
|
package reconcile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/device"
|
||||||
|
"yovision/sense/internal/metrics"
|
||||||
|
"yovision/sense/internal/onvif"
|
||||||
|
"yovision/sense/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultBatchSize = 128
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
ClaimDueReconcile(context.Context, store.ReconcileClaim) ([]store.ReconcileCandidate, error)
|
||||||
|
RenewReconcileLease(context.Context, string, string, string, time.Time, time.Duration) (bool, error)
|
||||||
|
CompleteReconcile(context.Context, string, int64, string, string, time.Time) error
|
||||||
|
FailReconcile(context.Context, string, int, time.Time, string, string, string, time.Time) error
|
||||||
|
ConvergenceSnapshot(context.Context) (store.ConvergenceSnapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaPaths interface {
|
||||||
|
EnsurePath(ctx context.Context, name, source string) (bool, error)
|
||||||
|
DeletePath(ctx context.Context, name string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Reconciler struct {
|
||||||
|
repository Repository
|
||||||
|
discovery onvif.Adapter
|
||||||
|
media MediaPaths
|
||||||
|
now func() time.Time
|
||||||
|
baseBackoff time.Duration
|
||||||
|
maxBackoff time.Duration
|
||||||
|
batchSize int
|
||||||
|
instanceID string
|
||||||
|
leaseDuration time.Duration
|
||||||
|
operationTimeout time.Duration
|
||||||
|
metrics *metrics.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
InstanceID string
|
||||||
|
LeaseDuration time.Duration
|
||||||
|
OperationTimeout time.Duration
|
||||||
|
Metrics *metrics.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repository Repository, discovery onvif.Adapter, media MediaPaths) *Reconciler {
|
||||||
|
return NewWithOptions(repository, discovery, media, Options{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWithOptions(
|
||||||
|
repository Repository,
|
||||||
|
discovery onvif.Adapter,
|
||||||
|
media MediaPaths,
|
||||||
|
options Options,
|
||||||
|
) *Reconciler {
|
||||||
|
if options.InstanceID == "" {
|
||||||
|
options.InstanceID = "single"
|
||||||
|
}
|
||||||
|
if options.LeaseDuration <= 0 {
|
||||||
|
options.LeaseDuration = 30 * time.Second
|
||||||
|
}
|
||||||
|
if options.OperationTimeout <= 0 {
|
||||||
|
options.OperationTimeout = 20 * time.Second
|
||||||
|
}
|
||||||
|
if options.OperationTimeout >= options.LeaseDuration {
|
||||||
|
options.OperationTimeout = options.LeaseDuration / 2
|
||||||
|
}
|
||||||
|
return &Reconciler{
|
||||||
|
repository: repository, discovery: discovery, media: media,
|
||||||
|
now: time.Now, baseBackoff: time.Second, maxBackoff: time.Minute, batchSize: defaultBatchSize,
|
||||||
|
instanceID: options.InstanceID, leaseDuration: options.LeaseDuration,
|
||||||
|
operationTimeout: options.OperationTimeout, metrics: options.Metrics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reconciler) RunOnce(ctx context.Context) (runErr error) {
|
||||||
|
started := time.Now()
|
||||||
|
defer func() {
|
||||||
|
if r.metrics != nil {
|
||||||
|
r.metrics.ObserveReconcileRun(runErr, time.Since(started))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
now := r.now().UTC()
|
||||||
|
token, err := newClaimToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
candidates, err := r.repository.ClaimDueReconcile(ctx, store.ReconcileClaim{
|
||||||
|
Owner: r.instanceID, Token: token, Now: now,
|
||||||
|
LeaseDuration: r.leaseDuration, Limit: r.batchSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("claim reconciliation candidates: %w", err)
|
||||||
|
}
|
||||||
|
var runErrors []error
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
itemNow := r.now().UTC()
|
||||||
|
renewed, err := r.repository.RenewReconcileLease(
|
||||||
|
ctx, candidate.Device.ID, r.instanceID, token, itemNow, r.leaseDuration,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
r.observeItem("error")
|
||||||
|
runErrors = append(runErrors, fmt.Errorf("renew reconcile device lease: %w", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !renewed {
|
||||||
|
r.observeItem("lease_lost")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemCtx, cancel := context.WithTimeout(ctx, r.operationTimeout)
|
||||||
|
err = r.reconcileOne(itemCtx, ctx, candidate, itemNow, r.instanceID, token)
|
||||||
|
cancel()
|
||||||
|
if errors.Is(err, store.ErrReconcileLeaseLost) {
|
||||||
|
r.observeItem("lease_lost")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
r.observeItem("error")
|
||||||
|
runErrors = append(runErrors, fmt.Errorf("reconcile device %s: %w", candidate.Device.ID, err))
|
||||||
|
} else {
|
||||||
|
r.observeItem("success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.metrics != nil {
|
||||||
|
snapshot, err := r.repository.ConvergenceSnapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
runErrors = append(runErrors, fmt.Errorf("read convergence metrics: %w", err))
|
||||||
|
} else {
|
||||||
|
r.metrics.SetConvergence(snapshot.Total, snapshot.Unconverged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(runErrors...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reconciler) reconcileOne(
|
||||||
|
ctx context.Context,
|
||||||
|
persistCtx context.Context,
|
||||||
|
candidate store.ReconcileCandidate,
|
||||||
|
now time.Time,
|
||||||
|
owner, token string,
|
||||||
|
) error {
|
||||||
|
if candidate.Device.DesiredState == device.DesiredDisabled {
|
||||||
|
if err := r.media.DeletePath(ctx, candidate.Device.PathName); err == nil {
|
||||||
|
return r.repository.CompleteReconcile(
|
||||||
|
persistCtx, candidate.Device.ID, candidate.Device.Generation, owner, token, now,
|
||||||
|
)
|
||||||
|
} else if persistCtx.Err() != nil {
|
||||||
|
return persistCtx.Err()
|
||||||
|
} else {
|
||||||
|
return r.persistFailure(persistCtx, candidate, now, owner, token, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := r.discovery.Probe(ctx, onvif.Target{
|
||||||
|
EndpointRef: candidate.Device.EndpointRef, CredentialRef: candidate.Device.CredentialRef,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
err = validateStreamURI(result.StreamURI)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
_, err = r.media.EnsurePath(ctx, candidate.Device.PathName, result.StreamURI)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return r.repository.CompleteReconcile(
|
||||||
|
persistCtx, candidate.Device.ID, candidate.Device.Generation, owner, token, now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if persistCtx.Err() != nil {
|
||||||
|
return persistCtx.Err()
|
||||||
|
}
|
||||||
|
return r.persistFailure(persistCtx, candidate, now, owner, token, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reconciler) persistFailure(
|
||||||
|
ctx context.Context,
|
||||||
|
candidate store.ReconcileCandidate,
|
||||||
|
now time.Time,
|
||||||
|
owner, token string,
|
||||||
|
err error,
|
||||||
|
) error {
|
||||||
|
failureCount := candidate.FailureCount + 1
|
||||||
|
nextAttempt := now.Add(r.backoff(failureCount))
|
||||||
|
errorCode := string(onvif.CodeOf(err))
|
||||||
|
var onvifError *onvif.Error
|
||||||
|
if !errors.As(err, &onvifError) {
|
||||||
|
errorCode = "media_error"
|
||||||
|
}
|
||||||
|
if markErr := r.repository.FailReconcile(
|
||||||
|
ctx, candidate.Device.ID, failureCount, nextAttempt, errorCode, owner, token, now,
|
||||||
|
); markErr != nil {
|
||||||
|
return errors.Join(err, fmt.Errorf("persist reconcile failure: %w", markErr))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reconciler) observeItem(result string) {
|
||||||
|
if r.metrics != nil {
|
||||||
|
r.metrics.ObserveReconcileItem(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClaimToken() (string, error) {
|
||||||
|
value := make([]byte, 18)
|
||||||
|
if _, err := rand.Read(value); err != nil {
|
||||||
|
return "", errors.New("generate reconcile claim token")
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateStreamURI(value string) error {
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "rtsp" && parsed.Scheme != "rtsps") {
|
||||||
|
return &onvif.Error{Code: onvif.ErrorInvalidReply, Err: fmt.Errorf("stream URI has invalid scheme or host")}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reconciler) backoff(failureCount int) time.Duration {
|
||||||
|
if failureCount <= 1 {
|
||||||
|
return r.baseBackoff
|
||||||
|
}
|
||||||
|
value := r.baseBackoff
|
||||||
|
for step := 1; step < failureCount; step++ {
|
||||||
|
if value >= r.maxBackoff/2 {
|
||||||
|
return r.maxBackoff
|
||||||
|
}
|
||||||
|
value *= 2
|
||||||
|
}
|
||||||
|
if value > r.maxBackoff {
|
||||||
|
return r.maxBackoff
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reconciler) Run(ctx context.Context, interval time.Duration, report func(error)) {
|
||||||
|
if err := r.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
|
||||||
|
report(err)
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := r.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
|
||||||
|
report(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package reconcile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/device"
|
||||||
|
"yovision/sense/internal/onvif"
|
||||||
|
"yovision/sense/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recordingMedia struct {
|
||||||
|
calls int
|
||||||
|
changed int
|
||||||
|
deleted int
|
||||||
|
paths map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *recordingMedia) EnsurePath(_ context.Context, name, source string) (bool, error) {
|
||||||
|
m.calls++
|
||||||
|
if m.paths == nil {
|
||||||
|
m.paths = make(map[string]string)
|
||||||
|
}
|
||||||
|
if m.paths[name] == source {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
m.paths[name] = source
|
||||||
|
m.changed++
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *recordingMedia) DeletePath(_ context.Context, name string) error {
|
||||||
|
m.calls++
|
||||||
|
if m.paths != nil {
|
||||||
|
delete(m.paths, name)
|
||||||
|
}
|
||||||
|
m.deleted++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileConvergesOnceAndPersistsGeneration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
|
||||||
|
createReconcileDevice(t, repository)
|
||||||
|
discovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||||
|
"onvif://camera-1": {Result: onvif.ProbeResult{
|
||||||
|
Profiles: []onvif.Profile{{Token: "main", Name: "Main", VideoEncoder: true}},
|
||||||
|
StreamURI: "rtsp://media.invalid/camera-1",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
media := &recordingMedia{}
|
||||||
|
reconciler := New(repository, discovery, media)
|
||||||
|
reconciler.now = func() time.Time { return time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC) }
|
||||||
|
if err := reconciler.RunOnce(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := reconciler.RunOnce(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.calls != 1 || media.changed != 1 {
|
||||||
|
t.Fatalf("converged generation should not repeat: calls=%d changed=%d", media.calls, media.changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackoffSurvivesStoreRestart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
databasePath := filepath.Join(t.TempDir(), "sense.db")
|
||||||
|
repository := openRepository(t, databasePath)
|
||||||
|
createReconcileDevice(t, repository)
|
||||||
|
failingDiscovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||||
|
"onvif://camera-1": {ProbeError: onvif.ErrorAuthentication},
|
||||||
|
})
|
||||||
|
media := &recordingMedia{}
|
||||||
|
initialTime := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
|
||||||
|
first := New(repository, failingDiscovery, media)
|
||||||
|
first.now = func() time.Time { return initialTime }
|
||||||
|
err := first.RunOnce(context.Background())
|
||||||
|
if err == nil || onvif.CodeOf(err) != onvif.ErrorAuthentication {
|
||||||
|
t.Fatalf("expected authentication failure, got %v", err)
|
||||||
|
}
|
||||||
|
if err := repository.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reopened, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(databasePath))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = reopened.Close() })
|
||||||
|
successDiscovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||||
|
"onvif://camera-1": {Result: onvif.ProbeResult{
|
||||||
|
Profiles: []onvif.Profile{{Token: "main", VideoEncoder: true}},
|
||||||
|
StreamURI: "rtsp://media.invalid/camera-1",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
afterRestart := New(reopened, successDiscovery, media)
|
||||||
|
afterRestart.now = func() time.Time { return initialTime.Add(500 * time.Millisecond) }
|
||||||
|
if err := afterRestart.RunOnce(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.calls != 0 {
|
||||||
|
t.Fatal("backoff window must survive restart")
|
||||||
|
}
|
||||||
|
afterRestart.now = func() time.Time { return initialTime.Add(time.Second) }
|
||||||
|
if err := afterRestart.RunOnce(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.calls != 1 {
|
||||||
|
t.Fatal("device must retry when persisted backoff expires")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancellationDoesNotPersistFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
|
||||||
|
createReconcileDevice(t, repository)
|
||||||
|
discovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||||
|
"onvif://camera-1": {
|
||||||
|
DelayMillis: 100,
|
||||||
|
Result: onvif.ProbeResult{
|
||||||
|
Profiles: []onvif.Profile{{Token: "main", VideoEncoder: true}},
|
||||||
|
StreamURI: "rtsp://media.invalid/camera-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reconciler := New(repository, discovery, &recordingMedia{})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
err := reconciler.RunOnce(ctx)
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("expected cancellation, got %v", err)
|
||||||
|
}
|
||||||
|
candidates, err := repository.ListDueReconcile(context.Background(), time.Now().Add(time.Hour), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 || candidates[0].FailureCount != 0 {
|
||||||
|
t.Fatalf("cancellation must not consume retry budget: %+v", candidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisabledDeviceDeletesOnlyItsExactPath(t *testing.T) {
|
||||||
|
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
|
||||||
|
createReconcileDevice(t, repository)
|
||||||
|
media := &recordingMedia{paths: map[string]string{"camera-1": "source", "other": "keep"}}
|
||||||
|
if err := repository.SetDesiredState(context.Background(), "camera-1", device.DesiredDisabled); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reconciler := New(repository, onvif.NewFake(nil), media)
|
||||||
|
if err := reconciler.RunOnce(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.deleted != 1 || media.paths["other"] != "keep" {
|
||||||
|
t.Fatalf("disabled convergence touched the wrong paths: %+v", media.paths)
|
||||||
|
}
|
||||||
|
value, err := repository.GetDevice(context.Background(), "camera-1")
|
||||||
|
if err != nil || value.ActualState != device.ActualOffline {
|
||||||
|
t.Fatalf("disabled device did not converge offline: %+v %v", value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type lostLeaseRepository struct {
|
||||||
|
completed bool
|
||||||
|
failed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *lostLeaseRepository) ClaimDueReconcile(
|
||||||
|
context.Context,
|
||||||
|
store.ReconcileClaim,
|
||||||
|
) ([]store.ReconcileCandidate, error) {
|
||||||
|
return []store.ReconcileCandidate{{Device: device.Device{
|
||||||
|
ID: "camera-lost", DesiredState: device.DesiredEnabled,
|
||||||
|
EndpointRef: "onvif://camera-lost", PathName: "camera-lost", Generation: 1,
|
||||||
|
}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *lostLeaseRepository) RenewReconcileLease(
|
||||||
|
context.Context, string, string, string, time.Time, time.Duration,
|
||||||
|
) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *lostLeaseRepository) CompleteReconcile(
|
||||||
|
context.Context, string, int64, string, string, time.Time,
|
||||||
|
) error {
|
||||||
|
r.completed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *lostLeaseRepository) FailReconcile(
|
||||||
|
context.Context, string, int, time.Time, string, string, string, time.Time,
|
||||||
|
) error {
|
||||||
|
r.failed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *lostLeaseRepository) ConvergenceSnapshot(context.Context) (store.ConvergenceSnapshot, error) {
|
||||||
|
return store.ConvergenceSnapshot{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLostLeaseSkipsAllExternalAndStoreMutations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
repository := &lostLeaseRepository{}
|
||||||
|
media := &recordingMedia{}
|
||||||
|
reconciler := New(repository, onvif.NewFake(nil), media)
|
||||||
|
if err := reconciler.RunOnce(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.calls != 0 || repository.completed || repository.failed {
|
||||||
|
t.Fatalf("lost lease performed a side effect: media=%d completed=%v failed=%v",
|
||||||
|
media.calls, repository.completed, repository.failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPerItemTimeoutPersistsRetryWhenParentIsAlive(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
|
||||||
|
createReconcileDevice(t, repository)
|
||||||
|
discovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||||
|
"onvif://camera-1": {
|
||||||
|
DelayMillis: 100,
|
||||||
|
Result: onvif.ProbeResult{StreamURI: "rtsp://media.invalid/camera-1"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reconciler := NewWithOptions(repository, discovery, &recordingMedia{}, Options{
|
||||||
|
InstanceID: "ins_test", LeaseDuration: time.Second, OperationTimeout: 5 * time.Millisecond,
|
||||||
|
})
|
||||||
|
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||||
|
reconciler.now = func() time.Time { return now }
|
||||||
|
if err := reconciler.RunOnce(context.Background()); err == nil {
|
||||||
|
t.Fatal("expected bounded operation timeout")
|
||||||
|
}
|
||||||
|
candidates, err := repository.ListDueReconcile(context.Background(), now.Add(time.Hour), 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 || candidates[0].FailureCount != 1 {
|
||||||
|
t.Fatalf("operation timeout did not persist retry state: %+v", candidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openRepository(t *testing.T, path string) *store.SQLite {
|
||||||
|
t.Helper()
|
||||||
|
repository, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = repository.Close() })
|
||||||
|
return repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func createReconcileDevice(t *testing.T, repository *store.SQLite) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := repository.EnsureSite(ctx, device.Site{TenantID: "tenant", ID: "site", Name: "Site"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repository.CreateDevice(ctx, device.Device{
|
||||||
|
ID: "camera-1", TenantID: "tenant", SiteID: "site", SerialNumber: "camera-1", Name: "Camera 1",
|
||||||
|
Modality: device.ModalityVideo, Capabilities: []device.Capability{device.CapabilityVideoCapture},
|
||||||
|
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||||
|
EndpointRef: "onvif://camera-1", CredentialRef: "secret://camera-1", PathName: "camera-1",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditActorType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuditActorUser AuditActorType = "user"
|
||||||
|
AuditActorService AuditActorType = "service"
|
||||||
|
AuditActorSystem AuditActorType = "system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditContext is supplied by a verified caller boundary. Repository callers
|
||||||
|
// without an HTTP principal use the explicit system/sense fallback.
|
||||||
|
type AuditContext struct {
|
||||||
|
ActorType AuditActorType
|
||||||
|
ActorID string
|
||||||
|
Reason string
|
||||||
|
TraceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type auditContextKey struct{}
|
||||||
|
|
||||||
|
func WithAuditContext(ctx context.Context, value AuditContext) context.Context {
|
||||||
|
return context.WithValue(ctx, auditContextKey{}, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditFromContext(ctx context.Context) AuditContext {
|
||||||
|
value, ok := ctx.Value(auditContextKey{}).(AuditContext)
|
||||||
|
if !ok {
|
||||||
|
return AuditContext{ActorType: AuditActorSystem, ActorID: "sense"}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAudit(value AuditContext) error {
|
||||||
|
if value.ActorType != AuditActorUser && value.ActorType != AuditActorService && value.ActorType != AuditActorSystem {
|
||||||
|
return errors.New("invalid audit actor type")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(value.ActorID) == "" || len(value.ActorID) > 200 {
|
||||||
|
return errors.New("invalid audit actor ID")
|
||||||
|
}
|
||||||
|
if len(value.Reason) > 500 {
|
||||||
|
return errors.New("audit reason is too long")
|
||||||
|
}
|
||||||
|
if len(value.TraceID) > 128 {
|
||||||
|
return errors.New("audit trace ID is too long")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type postgresAuditEvent struct {
|
||||||
|
EventType string
|
||||||
|
TenantID string
|
||||||
|
SiteID string
|
||||||
|
DeviceID string
|
||||||
|
Generation int64
|
||||||
|
QuotaSourceVersion int64
|
||||||
|
AreaPolicySourceVersion int64
|
||||||
|
Payload any
|
||||||
|
OccurredAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertPostgresAudit(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *sql.Tx,
|
||||||
|
event postgresAuditEvent,
|
||||||
|
) error {
|
||||||
|
audit := auditFromContext(ctx)
|
||||||
|
if err := validateAudit(audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
eventID, err := newAuditEventID()
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("generate audit event ID")
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(event.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("encode audit payload")
|
||||||
|
}
|
||||||
|
if !json.Valid(payload) {
|
||||||
|
return errors.New("invalid audit payload")
|
||||||
|
}
|
||||||
|
occurredAt := event.OccurredAt.UTC()
|
||||||
|
if occurredAt.IsZero() {
|
||||||
|
occurredAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `INSERT INTO sense.device_operation_outbox(
|
||||||
|
event_id, event_type, tenant_id, site_id, device_id,
|
||||||
|
actor_type, actor_id, reason, trace_id, aggregate_generation,
|
||||||
|
quota_source_version, area_policy_source_version, payload,
|
||||||
|
occurred_at, available_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''), NULLIF($9, ''),
|
||||||
|
$10, $11, $12, $13, $14, $14)`,
|
||||||
|
eventID, event.EventType, event.TenantID, event.SiteID, event.DeviceID,
|
||||||
|
audit.ActorType, audit.ActorID, audit.Reason, audit.TraceID,
|
||||||
|
event.Generation, nullableVersion(event.QuotaSourceVersion),
|
||||||
|
nullableVersion(event.AreaPolicySourceVersion), payload, occurredAt)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("insert device operation audit")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuditEventID() (string, error) {
|
||||||
|
value := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(value); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "audit_" + hex.EncodeToString(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableVersion(value int64) any {
|
||||||
|
if value < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrETagMismatch = errors.New("device ETag mismatch")
|
||||||
|
ErrIdempotencyConflict = errors.New("idempotency key body conflict")
|
||||||
|
ErrDuplicateSerialNumber = errors.New("duplicate device serial number")
|
||||||
|
)
|
||||||
|
|
||||||
|
type ControlProjectionVersions struct {
|
||||||
|
QuotaSourceVersion *int64 `json:"quota_source_version"`
|
||||||
|
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
|
||||||
|
SyncedAt *time.Time `json:"synced_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlDevice is deliberately safe to serialize. It contains configured
|
||||||
|
// booleans, never endpoint, credential or profile-token values.
|
||||||
|
type ControlDevice struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
SiteID string `json:"site_id"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Modality device.Modality `json:"modality"`
|
||||||
|
Capabilities []device.Capability `json:"capabilities"`
|
||||||
|
AreaID string `json:"area_id"`
|
||||||
|
DesiredState device.DesiredState `json:"desired_state"`
|
||||||
|
ActualState device.ActualState `json:"actual_state"`
|
||||||
|
AdapterStatus string `json:"adapter_status"`
|
||||||
|
EndpointConfigured bool `json:"endpoint_configured"`
|
||||||
|
CredentialConfigured bool `json:"credential_configured"`
|
||||||
|
Generation int64 `json:"generation"`
|
||||||
|
ObservedGeneration int64 `json:"observed_generation"`
|
||||||
|
Converged bool `json:"converged"`
|
||||||
|
FailureCount int `json:"failure_count"`
|
||||||
|
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
|
||||||
|
LastErrorCode *string `json:"last_error_code,omitempty"`
|
||||||
|
ProjectionVersions ControlProjectionVersions `json:"projection_versions"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
ResourceVersion int64 `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlSiteQuota struct {
|
||||||
|
Status string
|
||||||
|
UsedVideoChannels int
|
||||||
|
MaxVideoChannels *int
|
||||||
|
AvailableVideoChannels *int
|
||||||
|
OverLimit bool
|
||||||
|
SourceVersion *int64
|
||||||
|
SyncedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlListFilter struct {
|
||||||
|
Limit int
|
||||||
|
AfterCreated *time.Time
|
||||||
|
AfterDeviceID string
|
||||||
|
Modality *device.Modality
|
||||||
|
Capability *device.Capability
|
||||||
|
DesiredState *device.DesiredState
|
||||||
|
ActualState *device.ActualState
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlDevicePage struct {
|
||||||
|
Items []ControlDevice
|
||||||
|
HasMore bool
|
||||||
|
Quota ControlSiteQuota
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdempotencyScope struct {
|
||||||
|
PrincipalID string
|
||||||
|
TenantID string
|
||||||
|
SiteID string
|
||||||
|
Operation string
|
||||||
|
Key string
|
||||||
|
RequestHash [sha256.Size]byte
|
||||||
|
TraceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlCreateRequest struct {
|
||||||
|
Scope IdempotencyScope
|
||||||
|
Device device.Device
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlCreateResult struct {
|
||||||
|
Device ControlDevice
|
||||||
|
AcceptedAt time.Time
|
||||||
|
TraceID string
|
||||||
|
ETag string
|
||||||
|
Location string
|
||||||
|
Replay bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlPatch struct {
|
||||||
|
Name *string
|
||||||
|
AreaID *string
|
||||||
|
EndpointRef *string
|
||||||
|
CredentialRef *string
|
||||||
|
ProfileToken *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlMutationResult struct {
|
||||||
|
Device ControlDevice
|
||||||
|
AcceptedAt time.Time
|
||||||
|
TraceID string
|
||||||
|
ETag string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlBatchItem struct {
|
||||||
|
DeviceID string
|
||||||
|
ETag string
|
||||||
|
DesiredState device.DesiredState
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlBatchRequest struct {
|
||||||
|
Scope IdempotencyScope
|
||||||
|
Reason string
|
||||||
|
Items []ControlBatchItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlBatchItemResult struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ErrorCode *string `json:"error_code"`
|
||||||
|
Message *string `json:"message"`
|
||||||
|
Generation *int64 `json:"generation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlBatchOperation struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID string `json:"-"`
|
||||||
|
SiteID string `json:"-"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SubmittedAt time.Time `json:"submitted_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at"`
|
||||||
|
Results []ControlBatchItemResult `json:"results"`
|
||||||
|
TraceID string `json:"trace_id"`
|
||||||
|
Replay bool `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlRepository interface {
|
||||||
|
ListControlDevices(context.Context, string, string, ControlListFilter) (ControlDevicePage, error)
|
||||||
|
CreateControlDevice(context.Context, ControlCreateRequest) (ControlCreateResult, error)
|
||||||
|
GetControlDevice(context.Context, string, string, string) (ControlDevice, error)
|
||||||
|
PatchControlDevice(context.Context, string, string, string, string, ControlPatch) (ControlMutationResult, error)
|
||||||
|
SetControlDesiredState(context.Context, string, string, string, string, device.DesiredState) (ControlMutationResult, error)
|
||||||
|
BatchSetControlDesiredState(context.Context, ControlBatchRequest) (ControlBatchOperation, error)
|
||||||
|
GetControlOperation(context.Context, string, string) (ControlBatchOperation, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeviceETag(deviceID string, resourceVersion int64) string {
|
||||||
|
digest := sha256.Sum256([]byte(deviceID + "\x00" + strconv.FormatInt(resourceVersion, 10)))
|
||||||
|
return `"` + base64.RawURLEncoding.EncodeToString(digest[:18]) + `"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,891 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"yovision/sense/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
const controlReceiptTTL = 24 * time.Hour
|
||||||
|
|
||||||
|
const controlDeviceColumns = `d.id, d.tenant_id, d.site_id, d.serial_number, d.name, d.modality,
|
||||||
|
d.area_id, d.desired_state, d.actual_state,
|
||||||
|
(d.endpoint_ref <> ''), (d.credential_ref <> ''),
|
||||||
|
d.generation, d.resource_version,
|
||||||
|
r.observed_generation, r.failure_count, r.next_attempt_at, r.last_error_code,
|
||||||
|
d.quota_source_version, d.area_policy_source_version,
|
||||||
|
GREATEST(
|
||||||
|
(SELECT q.synced_at FROM sense.site_quota_projection_state q
|
||||||
|
WHERE q.tenant_id = d.tenant_id AND q.site_id = d.site_id
|
||||||
|
AND q.source_version = d.quota_source_version),
|
||||||
|
(SELECT a.synced_at FROM sense.area_policy_projection_state a
|
||||||
|
WHERE a.tenant_id = d.tenant_id AND a.site_id = d.site_id
|
||||||
|
AND a.area_id = d.area_id AND a.source_version = d.area_policy_source_version)
|
||||||
|
),
|
||||||
|
d.created_at, d.updated_at,
|
||||||
|
COALESCE((SELECT jsonb_agg(c.capability ORDER BY c.capability)
|
||||||
|
FROM sense.device_capabilities c WHERE c.device_id = d.id), '[]'::jsonb)::text`
|
||||||
|
|
||||||
|
const controlDeviceSelect = `SELECT ` + controlDeviceColumns + `
|
||||||
|
FROM sense.devices d JOIN sense.reconcile_state r ON r.device_id = d.id`
|
||||||
|
|
||||||
|
type controlScanner interface {
|
||||||
|
Scan(...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanControlDevice(row controlScanner) (ControlDevice, error) {
|
||||||
|
var value ControlDevice
|
||||||
|
var nextAttempt, syncedAt sql.NullTime
|
||||||
|
var lastError sql.NullString
|
||||||
|
var quotaVersion, areaVersion sql.NullInt64
|
||||||
|
var capabilitiesJSON string
|
||||||
|
err := row.Scan(
|
||||||
|
&value.ID, &value.TenantID, &value.SiteID, &value.SerialNumber, &value.Name,
|
||||||
|
&value.Modality, &value.AreaID, &value.DesiredState, &value.ActualState,
|
||||||
|
&value.EndpointConfigured, &value.CredentialConfigured,
|
||||||
|
&value.Generation, &value.ResourceVersion, &value.ObservedGeneration,
|
||||||
|
&value.FailureCount, &nextAttempt, &lastError, "aVersion, &areaVersion,
|
||||||
|
&syncedAt, &value.CreatedAt, &value.UpdatedAt, &capabilitiesJSON,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return ControlDevice{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(capabilitiesJSON), &value.Capabilities); err != nil {
|
||||||
|
return ControlDevice{}, errors.New("decode postgres control device capabilities")
|
||||||
|
}
|
||||||
|
if value.Capabilities == nil {
|
||||||
|
value.Capabilities = make([]device.Capability, 0)
|
||||||
|
}
|
||||||
|
if nextAttempt.Valid {
|
||||||
|
point := nextAttempt.Time.UTC()
|
||||||
|
value.NextAttemptAt = &point
|
||||||
|
}
|
||||||
|
if lastError.Valid {
|
||||||
|
code := lastError.String
|
||||||
|
value.LastErrorCode = &code
|
||||||
|
}
|
||||||
|
if quotaVersion.Valid {
|
||||||
|
version := quotaVersion.Int64
|
||||||
|
value.ProjectionVersions.QuotaSourceVersion = &version
|
||||||
|
}
|
||||||
|
if areaVersion.Valid {
|
||||||
|
version := areaVersion.Int64
|
||||||
|
value.ProjectionVersions.AreaPolicySourceVersion = &version
|
||||||
|
}
|
||||||
|
if syncedAt.Valid {
|
||||||
|
point := syncedAt.Time.UTC()
|
||||||
|
value.ProjectionVersions.SyncedAt = &point
|
||||||
|
}
|
||||||
|
value.Converged = value.ObservedGeneration >= value.Generation && value.FailureCount == 0
|
||||||
|
value.AdapterStatus = controlAdapterStatus(value)
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func controlAdapterStatus(value ControlDevice) string {
|
||||||
|
if value.LastErrorCode != nil {
|
||||||
|
switch *value.LastErrorCode {
|
||||||
|
case "authentication_failed":
|
||||||
|
return "authentication_failed"
|
||||||
|
case "adapter_not_ready":
|
||||||
|
return "adapter_not_ready"
|
||||||
|
default:
|
||||||
|
return "unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.Converged {
|
||||||
|
return "ready"
|
||||||
|
}
|
||||||
|
if value.ActualState == device.ActualFailed || value.ActualState == device.ActualOffline {
|
||||||
|
return "unavailable"
|
||||||
|
}
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) GetControlDevice(
|
||||||
|
ctx context.Context, tenantID, siteID, deviceID string,
|
||||||
|
) (ControlDevice, error) {
|
||||||
|
value, err := scanControlDevice(s.db.QueryRowContext(ctx, controlDeviceSelect+`
|
||||||
|
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, tenantID, siteID, deviceID))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ControlDevice{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ControlDevice{}, errors.New("get postgres control device")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) ListControlDevices(
|
||||||
|
ctx context.Context, tenantID, siteID string, filter ControlListFilter,
|
||||||
|
) (ControlDevicePage, error) {
|
||||||
|
quota, err := s.controlSiteQuota(ctx, tenantID, siteID)
|
||||||
|
if err != nil {
|
||||||
|
return ControlDevicePage{}, err
|
||||||
|
}
|
||||||
|
query := controlDeviceSelect + ` WHERE d.tenant_id = $1 AND d.site_id = $2`
|
||||||
|
arguments := []any{tenantID, siteID}
|
||||||
|
appendCondition := func(clause string, value any) {
|
||||||
|
arguments = append(arguments, value)
|
||||||
|
query += fmt.Sprintf(clause, len(arguments))
|
||||||
|
}
|
||||||
|
if filter.Modality != nil {
|
||||||
|
appendCondition(` AND d.modality = $%d`, *filter.Modality)
|
||||||
|
}
|
||||||
|
if filter.Capability != nil {
|
||||||
|
appendCondition(` AND EXISTS (SELECT 1 FROM sense.device_capabilities fc
|
||||||
|
WHERE fc.device_id = d.id AND fc.capability = $%d)`, *filter.Capability)
|
||||||
|
}
|
||||||
|
if filter.DesiredState != nil {
|
||||||
|
appendCondition(` AND d.desired_state = $%d`, *filter.DesiredState)
|
||||||
|
}
|
||||||
|
if filter.ActualState != nil {
|
||||||
|
appendCondition(` AND d.actual_state = $%d`, *filter.ActualState)
|
||||||
|
}
|
||||||
|
if filter.AfterCreated != nil {
|
||||||
|
arguments = append(arguments, filter.AfterCreated.UTC(), filter.AfterDeviceID)
|
||||||
|
query += fmt.Sprintf(` AND (d.created_at, d.id) > ($%d, $%d)`, len(arguments)-1, len(arguments))
|
||||||
|
}
|
||||||
|
arguments = append(arguments, filter.Limit+1)
|
||||||
|
query += fmt.Sprintf(` ORDER BY d.created_at ASC, d.id ASC LIMIT $%d`, len(arguments))
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, arguments...)
|
||||||
|
if err != nil {
|
||||||
|
return ControlDevicePage{}, errors.New("list postgres control devices")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
values := make([]ControlDevice, 0, filter.Limit+1)
|
||||||
|
for rows.Next() {
|
||||||
|
value, scanErr := scanControlDevice(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return ControlDevicePage{}, errors.New("scan postgres control device page")
|
||||||
|
}
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return ControlDevicePage{}, errors.New("iterate postgres control device page")
|
||||||
|
}
|
||||||
|
hasMore := len(values) > filter.Limit
|
||||||
|
if hasMore {
|
||||||
|
values = values[:filter.Limit]
|
||||||
|
}
|
||||||
|
return ControlDevicePage{Items: values, HasMore: hasMore, Quota: quota}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) controlSiteQuota(ctx context.Context, tenantID, siteID string) (ControlSiteQuota, error) {
|
||||||
|
var maximum int
|
||||||
|
var sourceVersion int64
|
||||||
|
var syncedAt time.Time
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT max_video_channels, source_version, source_updated_at
|
||||||
|
FROM bell.site_quota_v1 WHERE tenant_id = $1 AND site_id = $2`, tenantID, siteID).
|
||||||
|
Scan(&maximum, &sourceVersion, &syncedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ControlSiteQuota{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ControlSiteQuota{}, errors.New("read postgres control site quota")
|
||||||
|
}
|
||||||
|
var used int
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sense.devices d
|
||||||
|
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.desired_state = 'enabled'
|
||||||
|
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||||
|
WHERE c.device_id = d.id AND c.capability = 'video_capture')`, tenantID, siteID).Scan(&used); err != nil {
|
||||||
|
return ControlSiteQuota{}, errors.New("count postgres control site video channels")
|
||||||
|
}
|
||||||
|
status := "current"
|
||||||
|
if maximum < 1 || maximum > device.MaximumVideoChannels || sourceVersion < 1 || syncedAt.IsZero() {
|
||||||
|
status = "invalid"
|
||||||
|
return ControlSiteQuota{Status: status, UsedVideoChannels: used, OverLimit: false}, nil
|
||||||
|
}
|
||||||
|
available := maximum - used
|
||||||
|
if available < 0 {
|
||||||
|
available = 0
|
||||||
|
}
|
||||||
|
point := syncedAt.UTC()
|
||||||
|
return ControlSiteQuota{
|
||||||
|
Status: status, UsedVideoChannels: used, MaxVideoChannels: &maximum,
|
||||||
|
AvailableVideoChannels: &available, OverLimit: used > maximum,
|
||||||
|
SourceVersion: &sourceVersion, SyncedAt: &point,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type controlReceipt struct {
|
||||||
|
Status int
|
||||||
|
Body []byte
|
||||||
|
ETag string
|
||||||
|
Location string
|
||||||
|
TraceID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func controlScopeHash(scope IdempotencyScope) [sha256.Size]byte {
|
||||||
|
encoded, _ := json.Marshal([]string{
|
||||||
|
scope.PrincipalID, scope.TenantID, scope.SiteID, scope.Operation, scope.Key,
|
||||||
|
})
|
||||||
|
return sha256.Sum256(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readControlReceipt(
|
||||||
|
ctx context.Context, tx *sql.Tx, scope IdempotencyScope, now time.Time,
|
||||||
|
) (controlReceipt, bool, error) {
|
||||||
|
var receipt controlReceipt
|
||||||
|
scopeHash := controlScopeHash(scope)
|
||||||
|
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, hex.EncodeToString(scopeHash[:])); err != nil {
|
||||||
|
return receipt, false, errors.New("lock postgres Control API idempotency scope")
|
||||||
|
}
|
||||||
|
var storedRequestHash []byte
|
||||||
|
var etag, location sql.NullString
|
||||||
|
var expiresAt time.Time
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT request_hash, response_status, response_body::text,
|
||||||
|
response_etag, response_location, trace_id, created_at, expires_at
|
||||||
|
FROM sense.control_idempotency_receipts WHERE scope_hash = $1`, scopeHash[:]).
|
||||||
|
Scan(&storedRequestHash, &receipt.Status, &receipt.Body, &etag, &location,
|
||||||
|
&receipt.TraceID, &receipt.CreatedAt, &expiresAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return receipt, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return receipt, false, errors.New("read postgres Control API idempotency receipt")
|
||||||
|
}
|
||||||
|
if !expiresAt.After(now) {
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM sense.control_idempotency_receipts
|
||||||
|
WHERE scope_hash = $1`, scopeHash[:]); err != nil {
|
||||||
|
return receipt, false, errors.New("expire postgres Control API idempotency receipt")
|
||||||
|
}
|
||||||
|
return controlReceipt{}, false, nil
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare(storedRequestHash, scope.RequestHash[:]) != 1 {
|
||||||
|
return receipt, false, ErrIdempotencyConflict
|
||||||
|
}
|
||||||
|
receipt.ETag = etag.String
|
||||||
|
receipt.Location = location.String
|
||||||
|
return receipt, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeControlReceipt(
|
||||||
|
ctx context.Context, tx *sql.Tx, scope IdempotencyScope, receipt controlReceipt,
|
||||||
|
) error {
|
||||||
|
scopeHash := controlScopeHash(scope)
|
||||||
|
_, err := tx.ExecContext(ctx, `INSERT INTO sense.control_idempotency_receipts(
|
||||||
|
scope_hash, request_hash, operation_name, principal_id, tenant_id, site_id,
|
||||||
|
response_status, response_body, response_etag, response_location, trace_id,
|
||||||
|
created_at, expires_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, NULLIF($9, ''), NULLIF($10, ''), $11, $12, $13)`,
|
||||||
|
scopeHash[:], scope.RequestHash[:], scope.Operation, scope.PrincipalID,
|
||||||
|
scope.TenantID, scope.SiteID, receipt.Status, string(receipt.Body),
|
||||||
|
receipt.ETag, receipt.Location, receipt.TraceID, receipt.CreatedAt,
|
||||||
|
receipt.CreatedAt.Add(controlReceiptTTL))
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("write postgres Control API idempotency receipt")
|
||||||
|
}
|
||||||
|
// Bound opportunistic cleanup; never scans or deletes unexpired receipts.
|
||||||
|
_, _ = tx.ExecContext(ctx, `DELETE FROM sense.control_idempotency_receipts
|
||||||
|
WHERE scope_hash IN (SELECT scope_hash FROM sense.control_idempotency_receipts
|
||||||
|
WHERE expires_at <= $1 ORDER BY expires_at LIMIT 32)`, receipt.CreatedAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) CreateControlDevice(
|
||||||
|
ctx context.Context, request ControlCreateRequest,
|
||||||
|
) (ControlCreateResult, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ControlCreateResult{}, errors.New("begin postgres Control API device create")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
receipt, found, err := readControlReceipt(ctx, tx, request.Scope, now)
|
||||||
|
if err != nil {
|
||||||
|
return ControlCreateResult{}, err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
var value ControlDevice
|
||||||
|
if err := json.Unmarshal(receipt.Body, &value); err != nil {
|
||||||
|
return ControlCreateResult{}, errors.New("decode postgres device creation receipt")
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return ControlCreateResult{}, errors.New("commit postgres device creation replay")
|
||||||
|
}
|
||||||
|
return ControlCreateResult{
|
||||||
|
Device: value, AcceptedAt: receipt.CreatedAt, TraceID: receipt.TraceID,
|
||||||
|
ETag: receipt.ETag, Location: receipt.Location, Replay: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
value := request.Device
|
||||||
|
if value.Generation == 0 {
|
||||||
|
value.Generation = 1
|
||||||
|
}
|
||||||
|
if value.ResourceVersion == 0 {
|
||||||
|
value.ResourceVersion = 1
|
||||||
|
}
|
||||||
|
if value.ActualState == "" {
|
||||||
|
value.ActualState = device.ActualPending
|
||||||
|
}
|
||||||
|
value.CreatedAt = now
|
||||||
|
value.UpdatedAt = now
|
||||||
|
if err := createControlDeviceTx(ctx, tx, value, now); err != nil {
|
||||||
|
return ControlCreateResult{}, err
|
||||||
|
}
|
||||||
|
created, err := scanControlDevice(tx.QueryRowContext(ctx, controlDeviceSelect+`
|
||||||
|
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, value.TenantID, value.SiteID, value.ID))
|
||||||
|
if err != nil {
|
||||||
|
return ControlCreateResult{}, errors.New("read created postgres control device")
|
||||||
|
}
|
||||||
|
responseBody, err := json.Marshal(created)
|
||||||
|
if err != nil {
|
||||||
|
return ControlCreateResult{}, errors.New("encode created postgres control device")
|
||||||
|
}
|
||||||
|
etag := DeviceETag(created.ID, created.ResourceVersion)
|
||||||
|
location := "/api/v1/sites/" + url.PathEscape(created.SiteID) + "/devices/" + url.PathEscape(created.ID)
|
||||||
|
receipt = controlReceipt{
|
||||||
|
Status: 201, Body: responseBody, ETag: etag, Location: location,
|
||||||
|
TraceID: request.Scope.TraceID, CreatedAt: now,
|
||||||
|
}
|
||||||
|
if err := writeControlReceipt(ctx, tx, request.Scope, receipt); err != nil {
|
||||||
|
return ControlCreateResult{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return ControlCreateResult{}, errors.New("commit postgres Control API device create")
|
||||||
|
}
|
||||||
|
return ControlCreateResult{
|
||||||
|
Device: created, AcceptedAt: now, TraceID: receipt.TraceID,
|
||||||
|
ETag: etag, Location: location,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createControlDeviceTx(ctx context.Context, tx *sql.Tx, value device.Device, now time.Time) error {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("validate Control API device: %w", err)
|
||||||
|
}
|
||||||
|
areaVersion, err := checkPostgresAreaPolicy(
|
||||||
|
ctx, tx, value.TenantID, value.SiteID, value.AreaID,
|
||||||
|
value.HasCapability(device.CapabilityVideoCapture), now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var quotaVersion int64
|
||||||
|
if value.ConsumesVideoChannel() {
|
||||||
|
quotaVersion, err = checkPostgresVideoQuota(ctx, tx, value.TenantID, value.SiteID, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `INSERT INTO sense.devices(
|
||||||
|
id, tenant_id, site_id, area_id, serial_number, name, modality,
|
||||||
|
desired_state, actual_state, endpoint_ref, credential_ref, profile_token,
|
||||||
|
path_name, generation, resource_version, quota_source_version,
|
||||||
|
area_policy_source_version, created_at, updated_at
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)`,
|
||||||
|
value.ID, value.TenantID, value.SiteID, value.AreaID, value.SerialNumber, value.Name,
|
||||||
|
value.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
|
||||||
|
value.CredentialRef, value.ProfileToken, value.PathName, value.Generation,
|
||||||
|
value.ResourceVersion, nullableVersion(quotaVersion), areaVersion,
|
||||||
|
value.CreatedAt, value.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
var postgresError *pgconn.PgError
|
||||||
|
if errors.As(err, &postgresError) && postgresError.Code == "23505" &&
|
||||||
|
strings.Contains(postgresError.ConstraintName, "serial_number") {
|
||||||
|
return ErrDuplicateSerialNumber
|
||||||
|
}
|
||||||
|
return errors.New("insert postgres Control API device")
|
||||||
|
}
|
||||||
|
for _, capability := range sortedCapabilities(value.Capabilities) {
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.device_capabilities(device_id, capability)
|
||||||
|
VALUES ($1, $2)`, value.ID, capability); err != nil {
|
||||||
|
return errors.New("insert postgres Control API device capability")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.reconcile_state(device_id, updated_at)
|
||||||
|
VALUES ($1, $2)`, value.ID, now); err != nil {
|
||||||
|
return errors.New("insert postgres Control API reconcile state")
|
||||||
|
}
|
||||||
|
return insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||||
|
EventType: "device.created", TenantID: value.TenantID, SiteID: value.SiteID,
|
||||||
|
DeviceID: value.ID, Generation: value.Generation,
|
||||||
|
QuotaSourceVersion: quotaVersion, AreaPolicySourceVersion: areaVersion,
|
||||||
|
OccurredAt: now,
|
||||||
|
Payload: map[string]any{
|
||||||
|
"kind": "device_created", "area_id": value.AreaID, "modality": value.Modality,
|
||||||
|
"capabilities": sortedCapabilities(value.Capabilities), "desired_state": value.DesiredState,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ControlRepository = (*Postgres)(nil)
|
||||||
|
|
||||||
|
func (s *Postgres) PatchControlDevice(
|
||||||
|
ctx context.Context, tenantID, siteID, deviceID, expectedETag string, patch ControlPatch,
|
||||||
|
) (ControlMutationResult, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ControlMutationResult{}, errors.New("begin postgres Control API device patch")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var name, areaID, endpointRef, credentialRef, profileToken string
|
||||||
|
var generation, resourceVersion int64
|
||||||
|
var desired device.DesiredState
|
||||||
|
var quotaVersion, areaVersion sql.NullInt64
|
||||||
|
var hasVideo bool
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT d.name, d.area_id, d.endpoint_ref,
|
||||||
|
d.credential_ref, d.profile_token, d.generation, d.resource_version,
|
||||||
|
d.desired_state, d.quota_source_version, d.area_policy_source_version,
|
||||||
|
EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||||
|
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
||||||
|
FROM sense.devices d
|
||||||
|
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3 FOR UPDATE`,
|
||||||
|
tenantID, siteID, deviceID).Scan(
|
||||||
|
&name, &areaID, &endpointRef, &credentialRef, &profileToken,
|
||||||
|
&generation, &resourceVersion, &desired, "aVersion, &areaVersion, &hasVideo,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ControlMutationResult{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ControlMutationResult{}, errors.New("read postgres Control API device patch state")
|
||||||
|
}
|
||||||
|
if DeviceETag(deviceID, resourceVersion) != expectedETag {
|
||||||
|
return ControlMutationResult{}, ErrETagMismatch
|
||||||
|
}
|
||||||
|
changedFields := make([]string, 0, 5)
|
||||||
|
reconcileChanged := false
|
||||||
|
if patch.Name != nil && *patch.Name != name {
|
||||||
|
name = *patch.Name
|
||||||
|
changedFields = append(changedFields, "name")
|
||||||
|
}
|
||||||
|
if patch.AreaID != nil && *patch.AreaID != areaID {
|
||||||
|
version, policyErr := checkPostgresAreaPolicy(
|
||||||
|
ctx, tx, tenantID, siteID, *patch.AreaID, hasVideo, now,
|
||||||
|
)
|
||||||
|
if policyErr != nil {
|
||||||
|
return ControlMutationResult{}, policyErr
|
||||||
|
}
|
||||||
|
areaID = *patch.AreaID
|
||||||
|
areaVersion = sql.NullInt64{Int64: version, Valid: true}
|
||||||
|
changedFields = append(changedFields, "area_id")
|
||||||
|
}
|
||||||
|
if patch.EndpointRef != nil && *patch.EndpointRef != endpointRef {
|
||||||
|
endpointRef = *patch.EndpointRef
|
||||||
|
changedFields = append(changedFields, "endpoint_ref")
|
||||||
|
reconcileChanged = true
|
||||||
|
}
|
||||||
|
if patch.CredentialRef != nil && *patch.CredentialRef != credentialRef {
|
||||||
|
credentialRef = *patch.CredentialRef
|
||||||
|
changedFields = append(changedFields, "credential_ref")
|
||||||
|
reconcileChanged = true
|
||||||
|
}
|
||||||
|
if patch.ProfileToken != nil && *patch.ProfileToken != profileToken {
|
||||||
|
profileToken = *patch.ProfileToken
|
||||||
|
changedFields = append(changedFields, "profile_token")
|
||||||
|
reconcileChanged = true
|
||||||
|
}
|
||||||
|
if len(changedFields) > 0 {
|
||||||
|
resourceVersion++
|
||||||
|
if reconcileChanged {
|
||||||
|
generation++
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `UPDATE sense.devices SET
|
||||||
|
name = $1, area_id = $2, endpoint_ref = $3, credential_ref = $4,
|
||||||
|
profile_token = $5, generation = $6, resource_version = $7,
|
||||||
|
area_policy_source_version = $8,
|
||||||
|
actual_state = CASE WHEN $9 THEN 'pending' ELSE actual_state END,
|
||||||
|
updated_at = $10
|
||||||
|
WHERE tenant_id = $11 AND site_id = $12 AND id = $13`,
|
||||||
|
name, areaID, endpointRef, credentialRef, profileToken, generation,
|
||||||
|
resourceVersion, nullableVersion(areaVersion.Int64), reconcileChanged, now,
|
||||||
|
tenantID, siteID, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return ControlMutationResult{}, errors.New("update postgres Control API device configuration")
|
||||||
|
}
|
||||||
|
if reconcileChanged {
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||||
|
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||||
|
lease_owner = NULL, lease_token = NULL, lease_until = NULL,
|
||||||
|
updated_at = $1 WHERE device_id = $2`, now, deviceID); err != nil {
|
||||||
|
return ControlMutationResult{}, errors.New("reset postgres Control API reconcile state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||||
|
EventType: "device.configuration.accepted", TenantID: tenantID, SiteID: siteID,
|
||||||
|
DeviceID: deviceID, Generation: generation,
|
||||||
|
QuotaSourceVersion: quotaVersion.Int64, AreaPolicySourceVersion: areaVersion.Int64,
|
||||||
|
OccurredAt: now,
|
||||||
|
Payload: map[string]any{
|
||||||
|
"kind": "configuration_accepted", "changed": len(changedFields) > 0,
|
||||||
|
"changed_fields": changedFields, "area_id": areaID,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return ControlMutationResult{}, err
|
||||||
|
}
|
||||||
|
updated, err := scanControlDevice(tx.QueryRowContext(ctx, controlDeviceSelect+`
|
||||||
|
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, tenantID, siteID, deviceID))
|
||||||
|
if err != nil {
|
||||||
|
return ControlMutationResult{}, errors.New("read patched postgres control device")
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return ControlMutationResult{}, errors.New("commit postgres Control API device patch")
|
||||||
|
}
|
||||||
|
return ControlMutationResult{
|
||||||
|
Device: updated, AcceptedAt: now, TraceID: auditFromContext(ctx).TraceID,
|
||||||
|
ETag: DeviceETag(updated.ID, updated.ResourceVersion),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) SetControlDesiredState(
|
||||||
|
ctx context.Context, tenantID, siteID, deviceID, expectedETag string, desired device.DesiredState,
|
||||||
|
) (ControlMutationResult, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ControlMutationResult{}, errors.New("begin postgres Control API desired-state update")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
updated, err := setControlDesiredStateTx(
|
||||||
|
ctx, tx, tenantID, siteID, deviceID, expectedETag, desired, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return ControlMutationResult{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return ControlMutationResult{}, errors.New("commit postgres Control API desired-state update")
|
||||||
|
}
|
||||||
|
return ControlMutationResult{
|
||||||
|
Device: updated, AcceptedAt: now, TraceID: auditFromContext(ctx).TraceID,
|
||||||
|
ETag: DeviceETag(updated.ID, updated.ResourceVersion),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setControlDesiredStateTx(
|
||||||
|
ctx context.Context, tx *sql.Tx, tenantID, siteID, deviceID, expectedETag string,
|
||||||
|
desired device.DesiredState, now time.Time,
|
||||||
|
) (ControlDevice, error) {
|
||||||
|
var areaID, endpointRef, pathName string
|
||||||
|
var current device.DesiredState
|
||||||
|
var generation, resourceVersion int64
|
||||||
|
var quotaVersion, areaVersion sql.NullInt64
|
||||||
|
var hasVideo bool
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT d.area_id, d.desired_state, d.endpoint_ref,
|
||||||
|
d.path_name, d.generation, d.resource_version, d.quota_source_version,
|
||||||
|
d.area_policy_source_version,
|
||||||
|
EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||||
|
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
||||||
|
FROM sense.devices d
|
||||||
|
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3 FOR UPDATE`,
|
||||||
|
tenantID, siteID, deviceID).Scan(
|
||||||
|
&areaID, ¤t, &endpointRef, &pathName, &generation, &resourceVersion,
|
||||||
|
"aVersion, &areaVersion, &hasVideo,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ControlDevice{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ControlDevice{}, errors.New("read postgres Control API desired state")
|
||||||
|
}
|
||||||
|
if DeviceETag(deviceID, resourceVersion) != expectedETag {
|
||||||
|
return ControlDevice{}, ErrETagMismatch
|
||||||
|
}
|
||||||
|
if current != desired {
|
||||||
|
var admittedQuota, admittedArea int64
|
||||||
|
if desired == device.DesiredEnabled && hasVideo {
|
||||||
|
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
|
||||||
|
return ControlDevice{}, errors.New("video adapter configuration is incomplete")
|
||||||
|
}
|
||||||
|
admittedArea, err = checkPostgresAreaPolicy(ctx, tx, tenantID, siteID, areaID, true, now)
|
||||||
|
if err != nil {
|
||||||
|
return ControlDevice{}, err
|
||||||
|
}
|
||||||
|
admittedQuota, err = checkPostgresVideoQuota(ctx, tx, tenantID, siteID, now)
|
||||||
|
if err != nil {
|
||||||
|
return ControlDevice{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
generation++
|
||||||
|
resourceVersion++
|
||||||
|
err = tx.QueryRowContext(ctx, `UPDATE sense.devices SET
|
||||||
|
desired_state = $1, actual_state = 'pending', generation = $2,
|
||||||
|
resource_version = $3,
|
||||||
|
quota_source_version = COALESCE($4, quota_source_version),
|
||||||
|
area_policy_source_version = COALESCE($5, area_policy_source_version),
|
||||||
|
updated_at = $6
|
||||||
|
WHERE tenant_id = $7 AND site_id = $8 AND id = $9
|
||||||
|
RETURNING quota_source_version, area_policy_source_version`,
|
||||||
|
desired, generation, resourceVersion, nullableVersion(admittedQuota),
|
||||||
|
nullableVersion(admittedArea), now, tenantID, siteID, deviceID).
|
||||||
|
Scan("aVersion, &areaVersion)
|
||||||
|
if err != nil {
|
||||||
|
return ControlDevice{}, errors.New("update postgres Control API desired state")
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||||
|
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||||
|
lease_owner = NULL, lease_token = NULL, lease_until = NULL,
|
||||||
|
updated_at = $1 WHERE device_id = $2`, now, deviceID); err != nil {
|
||||||
|
return ControlDevice{}, errors.New("reset postgres Control API desired-state reconciliation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||||
|
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
|
||||||
|
DeviceID: deviceID, Generation: generation,
|
||||||
|
QuotaSourceVersion: quotaVersion.Int64, AreaPolicySourceVersion: areaVersion.Int64,
|
||||||
|
OccurredAt: now,
|
||||||
|
Payload: map[string]any{
|
||||||
|
"kind": "desired_state_accepted", "previous_desired_state": current,
|
||||||
|
"desired_state": desired, "changed": current != desired,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return ControlDevice{}, err
|
||||||
|
}
|
||||||
|
value, err := scanControlDevice(tx.QueryRowContext(ctx, controlDeviceSelect+`
|
||||||
|
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, tenantID, siteID, deviceID))
|
||||||
|
if err != nil {
|
||||||
|
return ControlDevice{}, errors.New("read updated postgres control desired state")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) BatchSetControlDesiredState(
|
||||||
|
ctx context.Context, request ControlBatchRequest,
|
||||||
|
) (ControlBatchOperation, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("begin postgres Control API batch")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
receipt, found, err := readControlReceipt(ctx, tx, request.Scope, now)
|
||||||
|
if err != nil {
|
||||||
|
return ControlBatchOperation{}, err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
var operation ControlBatchOperation
|
||||||
|
if err := json.Unmarshal(receipt.Body, &operation); err != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("decode postgres Control API batch receipt")
|
||||||
|
}
|
||||||
|
operation.TenantID = request.Scope.TenantID
|
||||||
|
operation.SiteID = request.Scope.SiteID
|
||||||
|
operation.Replay = true
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("commit postgres Control API batch replay")
|
||||||
|
}
|
||||||
|
return operation, nil
|
||||||
|
}
|
||||||
|
operationID, err := newControlOperationID(now)
|
||||||
|
if err != nil {
|
||||||
|
return ControlBatchOperation{}, err
|
||||||
|
}
|
||||||
|
counts := make(map[string]int, len(request.Items))
|
||||||
|
for _, item := range request.Items {
|
||||||
|
counts[item.DeviceID]++
|
||||||
|
}
|
||||||
|
if err := lockControlBatchDevices(
|
||||||
|
ctx, tx, request.Scope.TenantID, request.Scope.SiteID, counts,
|
||||||
|
); err != nil {
|
||||||
|
return ControlBatchOperation{}, err
|
||||||
|
}
|
||||||
|
results := make([]ControlBatchItemResult, 0, len(request.Items))
|
||||||
|
succeeded := 0
|
||||||
|
for index, item := range request.Items {
|
||||||
|
if counts[item.DeviceID] > 1 {
|
||||||
|
code, message := "invalid_request", "device_id is duplicated in this request"
|
||||||
|
results = append(results, ControlBatchItemResult{
|
||||||
|
DeviceID: item.DeviceID, Status: "rejected", ErrorCode: &code, Message: &message,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
savepoint := fmt.Sprintf("control_batch_%d", index)
|
||||||
|
if _, err := tx.ExecContext(ctx, "SAVEPOINT "+savepoint); err != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("create postgres Control API batch savepoint")
|
||||||
|
}
|
||||||
|
updated, itemErr := setControlDesiredStateTx(
|
||||||
|
ctx, tx, request.Scope.TenantID, request.Scope.SiteID,
|
||||||
|
item.DeviceID, item.ETag, item.DesiredState, now,
|
||||||
|
)
|
||||||
|
if itemErr != nil {
|
||||||
|
if _, rollbackErr := tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT "+savepoint); rollbackErr != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("rollback postgres Control API batch item")
|
||||||
|
}
|
||||||
|
code, status, message := controlBatchError(itemErr)
|
||||||
|
results = append(results, ControlBatchItemResult{
|
||||||
|
DeviceID: item.DeviceID, Status: status, ErrorCode: &code, Message: &message,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
generation := updated.Generation
|
||||||
|
results = append(results, ControlBatchItemResult{
|
||||||
|
DeviceID: item.DeviceID, Status: "succeeded", Generation: &generation,
|
||||||
|
})
|
||||||
|
succeeded++
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, "RELEASE SAVEPOINT "+savepoint); err != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("release postgres Control API batch savepoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := "partially_succeeded"
|
||||||
|
if succeeded == len(results) {
|
||||||
|
status = "succeeded"
|
||||||
|
} else if succeeded == 0 {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
completedAt := now
|
||||||
|
operation := ControlBatchOperation{
|
||||||
|
ID: operationID, TenantID: request.Scope.TenantID, SiteID: request.Scope.SiteID,
|
||||||
|
Status: status, SubmittedAt: now, CompletedAt: &completedAt,
|
||||||
|
Results: results, TraceID: request.Scope.TraceID,
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.batch_operations(
|
||||||
|
id, tenant_id, site_id, principal_id, status, trace_id, submitted_at, completed_at
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, operation.ID, operation.TenantID,
|
||||||
|
operation.SiteID, request.Scope.PrincipalID, operation.Status, operation.TraceID,
|
||||||
|
operation.SubmittedAt, operation.CompletedAt); err != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("insert postgres Control API batch operation")
|
||||||
|
}
|
||||||
|
for index, result := range results {
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.batch_operation_items(
|
||||||
|
operation_id, ordinal, device_id, status, error_code, message, generation
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7)`, operation.ID, index, result.DeviceID,
|
||||||
|
result.Status, result.ErrorCode, result.Message, result.Generation); err != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("insert postgres Control API batch item")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(operation)
|
||||||
|
if err != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("encode postgres Control API batch operation")
|
||||||
|
}
|
||||||
|
receipt = controlReceipt{
|
||||||
|
Status: 202, Body: body,
|
||||||
|
Location: "/api/v1/operations/" + url.PathEscape(operation.ID),
|
||||||
|
TraceID: operation.TraceID, CreatedAt: now,
|
||||||
|
}
|
||||||
|
if err := writeControlReceipt(ctx, tx, request.Scope, receipt); err != nil {
|
||||||
|
return ControlBatchOperation{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("commit postgres Control API batch")
|
||||||
|
}
|
||||||
|
return operation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockControlBatchDevices(
|
||||||
|
ctx context.Context, tx *sql.Tx, tenantID, siteID string, deviceIDs map[string]int,
|
||||||
|
) error {
|
||||||
|
ordered := make([]string, 0, len(deviceIDs))
|
||||||
|
for deviceID := range deviceIDs {
|
||||||
|
ordered = append(ordered, deviceID)
|
||||||
|
}
|
||||||
|
sort.Strings(ordered)
|
||||||
|
for _, deviceID := range ordered {
|
||||||
|
var locked string
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT id FROM sense.devices
|
||||||
|
WHERE tenant_id = $1 AND site_id = $2 AND id = $3 FOR UPDATE`,
|
||||||
|
tenantID, siteID, deviceID).Scan(&locked)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return errors.New("lock postgres Control API batch devices")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func controlBatchError(err error) (code, status, message string) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
return "not_found", "rejected", "device was not found"
|
||||||
|
case errors.Is(err, ErrETagMismatch):
|
||||||
|
return "etag_mismatch", "rejected", "device ETag does not match"
|
||||||
|
case errors.Is(err, ErrAreaPolicyDenied):
|
||||||
|
return "area_policy_denied", "rejected", "Area policy denies this change"
|
||||||
|
case errors.Is(err, ErrAreaPolicyUnavailable), errors.Is(err, ErrAreaPolicyInvalid):
|
||||||
|
return "area_policy_unavailable", "failed", "Area policy is unavailable"
|
||||||
|
case errors.Is(err, ErrQuotaProjectionUnavailable):
|
||||||
|
return "quota_projection_unavailable", "failed", "Site quota is unavailable"
|
||||||
|
case errors.Is(err, ErrQuotaProjectionInvalid):
|
||||||
|
return "quota_projection_invalid", "failed", "Site quota is invalid"
|
||||||
|
}
|
||||||
|
var quotaError *device.QuotaExceededError
|
||||||
|
if errors.As(err, "aError) {
|
||||||
|
return "quota_exceeded", "rejected", "Site video channel quota is exceeded"
|
||||||
|
}
|
||||||
|
return "service_unavailable", "failed", "device change could not be accepted"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) GetControlOperation(
|
||||||
|
ctx context.Context, tenantID, operationID string,
|
||||||
|
) (ControlBatchOperation, error) {
|
||||||
|
var value ControlBatchOperation
|
||||||
|
var completedAt sql.NullTime
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT id, tenant_id, site_id, status,
|
||||||
|
submitted_at, completed_at, trace_id
|
||||||
|
FROM sense.batch_operations WHERE tenant_id = $1 AND id = $2`, tenantID, operationID).
|
||||||
|
Scan(&value.ID, &value.TenantID, &value.SiteID, &value.Status,
|
||||||
|
&value.SubmittedAt, &completedAt, &value.TraceID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ControlBatchOperation{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("read postgres Control API batch operation")
|
||||||
|
}
|
||||||
|
if completedAt.Valid {
|
||||||
|
point := completedAt.Time.UTC()
|
||||||
|
value.CompletedAt = &point
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT device_id, status, error_code, message, generation
|
||||||
|
FROM sense.batch_operation_items WHERE operation_id = $1 ORDER BY ordinal`, operationID)
|
||||||
|
if err != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("list postgres Control API batch results")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
value.Results = make([]ControlBatchItemResult, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var item ControlBatchItemResult
|
||||||
|
var code, message sql.NullString
|
||||||
|
var generation sql.NullInt64
|
||||||
|
if err := rows.Scan(&item.DeviceID, &item.Status, &code, &message, &generation); err != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("scan postgres Control API batch result")
|
||||||
|
}
|
||||||
|
if code.Valid {
|
||||||
|
item.ErrorCode = &code.String
|
||||||
|
}
|
||||||
|
if message.Valid {
|
||||||
|
item.Message = &message.String
|
||||||
|
}
|
||||||
|
if generation.Valid {
|
||||||
|
item.Generation = &generation.Int64
|
||||||
|
}
|
||||||
|
value.Results = append(value.Results, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return ControlBatchOperation{}, errors.New("iterate postgres Control API batch results")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newControlOperationID(now time.Time) (string, error) {
|
||||||
|
value := make([]byte, 16)
|
||||||
|
milliseconds := uint64(now.UTC().UnixMilli())
|
||||||
|
value[0], value[1], value[2] = byte(milliseconds>>40), byte(milliseconds>>32), byte(milliseconds>>24)
|
||||||
|
value[3], value[4], value[5] = byte(milliseconds>>16), byte(milliseconds>>8), byte(milliseconds)
|
||||||
|
if _, err := rand.Read(value[6:]); err != nil {
|
||||||
|
return "", errors.New("generate Control API operation ID")
|
||||||
|
}
|
||||||
|
number := new(big.Int).SetBytes(value)
|
||||||
|
base, remainder := big.NewInt(32), new(big.Int)
|
||||||
|
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||||
|
encoded := make([]byte, 26)
|
||||||
|
for index := len(encoded) - 1; index >= 0; index-- {
|
||||||
|
number.QuoRem(number, base, remainder)
|
||||||
|
encoded[index] = alphabet[remainder.Int64()]
|
||||||
|
}
|
||||||
|
return "op_" + string(encoded), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
OperationalLeaseOrphanScan = "mediamtx-orphan-scan"
|
||||||
|
OperationalLeaseOrphanCleanup = "mediamtx-orphan-cleanup"
|
||||||
|
|
||||||
|
OrphanOwnedStale = "owned_stale"
|
||||||
|
OrphanUnowned = "unowned"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrOrphanScanNotFound = errors.New("orphan scan not found")
|
||||||
|
|
||||||
|
type MediaPathOwnership struct {
|
||||||
|
PathName string
|
||||||
|
DeviceID string
|
||||||
|
CurrentClaim bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrphanFinding struct {
|
||||||
|
PathName string
|
||||||
|
Classification string
|
||||||
|
DeviceID string
|
||||||
|
Deleted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrphanScan struct {
|
||||||
|
ID string
|
||||||
|
InstanceID string
|
||||||
|
ObservedCount int
|
||||||
|
OwnedStaleCount int
|
||||||
|
UnownedCount int
|
||||||
|
SafetyAllowed bool
|
||||||
|
SafetyReason string
|
||||||
|
CompletedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
Findings []OrphanFinding
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrphanRepository interface {
|
||||||
|
AcquireOperationalLease(
|
||||||
|
context.Context, string, string, string, time.Time, time.Duration,
|
||||||
|
) (bool, error)
|
||||||
|
ReleaseOperationalLease(context.Context, string, string, string, time.Time) error
|
||||||
|
ListMediaPathOwnership(context.Context) ([]MediaPathOwnership, error)
|
||||||
|
SaveOrphanScan(context.Context, OrphanScan, string, string) error
|
||||||
|
GetOrphanScan(context.Context, string) (OrphanScan, error)
|
||||||
|
RecordOrphanCleanup(
|
||||||
|
context.Context, string, string, string, string, string, time.Time,
|
||||||
|
) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ OrphanRepository = (*Postgres)(nil)
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const orphanReportRetention = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
func (s *Postgres) AcquireOperationalLease(
|
||||||
|
ctx context.Context,
|
||||||
|
name, owner, token string,
|
||||||
|
_ time.Time,
|
||||||
|
duration time.Duration,
|
||||||
|
) (bool, error) {
|
||||||
|
if strings.TrimSpace(name) == "" || strings.TrimSpace(owner) == "" ||
|
||||||
|
strings.TrimSpace(token) == "" || duration <= 0 {
|
||||||
|
return false, errors.New("invalid operational lease")
|
||||||
|
}
|
||||||
|
var acquired int
|
||||||
|
err := s.db.QueryRowContext(ctx, `INSERT INTO sense.operational_leases(
|
||||||
|
lease_name, owner_id, fencing_token, lease_until, updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3,
|
||||||
|
clock_timestamp() + ($4 * interval '1 second'), clock_timestamp()
|
||||||
|
)
|
||||||
|
ON CONFLICT (lease_name) DO UPDATE SET
|
||||||
|
owner_id = EXCLUDED.owner_id,
|
||||||
|
fencing_token = EXCLUDED.fencing_token,
|
||||||
|
lease_until = EXCLUDED.lease_until,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
WHERE sense.operational_leases.lease_until <= clock_timestamp()
|
||||||
|
OR (sense.operational_leases.owner_id = $2
|
||||||
|
AND sense.operational_leases.fencing_token = $3)
|
||||||
|
RETURNING 1`, name, owner, token, duration.Seconds()).Scan(&acquired)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.New("acquire postgres operational lease")
|
||||||
|
}
|
||||||
|
return acquired == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) ReleaseOperationalLease(
|
||||||
|
ctx context.Context,
|
||||||
|
name, owner, token string,
|
||||||
|
_ time.Time,
|
||||||
|
) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `UPDATE sense.operational_leases
|
||||||
|
SET lease_until = clock_timestamp(), updated_at = clock_timestamp()
|
||||||
|
WHERE lease_name = $1 AND owner_id = $2 AND fencing_token = $3`,
|
||||||
|
name, owner, token)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("release postgres operational lease")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) ListMediaPathOwnership(ctx context.Context) ([]MediaPathOwnership, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||||
|
o.path_name, o.device_id,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM sense.devices d
|
||||||
|
WHERE d.id = o.device_id AND d.path_name = o.path_name
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM sense.device_capabilities c
|
||||||
|
WHERE c.device_id = d.id AND c.capability = 'video_capture'
|
||||||
|
)
|
||||||
|
) AS current_claim
|
||||||
|
FROM sense.media_path_ownership o
|
||||||
|
ORDER BY o.path_name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("list postgres MediaMTX path ownership")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
values := make([]MediaPathOwnership, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var value MediaPathOwnership
|
||||||
|
if err := rows.Scan(&value.PathName, &value.DeviceID, &value.CurrentClaim); err != nil {
|
||||||
|
return nil, errors.New("scan postgres MediaMTX path ownership")
|
||||||
|
}
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, errors.New("iterate postgres MediaMTX path ownership")
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) SaveOrphanScan(
|
||||||
|
ctx context.Context,
|
||||||
|
scan OrphanScan,
|
||||||
|
owner, token string,
|
||||||
|
) error {
|
||||||
|
if err := validateOrphanScan(scan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("begin postgres orphan scan save")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var lease int
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT 1 FROM sense.operational_leases
|
||||||
|
WHERE lease_name = $1 AND owner_id = $2 AND fencing_token = $3
|
||||||
|
AND lease_until > clock_timestamp()
|
||||||
|
FOR UPDATE`, OperationalLeaseOrphanScan, owner, token).Scan(&lease)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrOperationalLeaseLost
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("verify postgres orphan scan lease")
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.orphan_scan_runs(
|
||||||
|
id, instance_id, observed_count, owned_stale_count, unowned_count,
|
||||||
|
safety_allowed, safety_reason, completed_at, expires_at
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||||
|
scan.ID, scan.InstanceID, scan.ObservedCount, scan.OwnedStaleCount, scan.UnownedCount,
|
||||||
|
scan.SafetyAllowed, scan.SafetyReason, scan.CompletedAt, scan.ExpiresAt,
|
||||||
|
); err != nil {
|
||||||
|
return errors.New("insert postgres orphan scan")
|
||||||
|
}
|
||||||
|
for _, finding := range scan.Findings {
|
||||||
|
var deviceID any
|
||||||
|
if finding.DeviceID != "" {
|
||||||
|
deviceID = finding.DeviceID
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.orphan_scan_findings(
|
||||||
|
scan_id, path_name, classification, device_id
|
||||||
|
) VALUES ($1,$2,$3,$4)`, scan.ID, finding.PathName, finding.Classification, deviceID); err != nil {
|
||||||
|
return errors.New("insert postgres orphan scan finding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE sense.operational_leases
|
||||||
|
SET lease_until = clock_timestamp(), updated_at = clock_timestamp()
|
||||||
|
WHERE lease_name = $1 AND owner_id = $2 AND fencing_token = $3`,
|
||||||
|
OperationalLeaseOrphanScan, owner, token); err != nil {
|
||||||
|
return errors.New("release postgres orphan scan lease")
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM sense.orphan_scan_runs r
|
||||||
|
WHERE r.completed_at < $1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sense.orphan_cleanup_actions a WHERE a.scan_id = r.id
|
||||||
|
)`, scan.CompletedAt.Add(-orphanReportRetention)); err != nil {
|
||||||
|
return errors.New("expire postgres orphan scan reports")
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return errors.New("commit postgres orphan scan")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOrphanScan(scan OrphanScan) error {
|
||||||
|
if strings.TrimSpace(scan.ID) == "" || strings.TrimSpace(scan.InstanceID) == "" ||
|
||||||
|
scan.ObservedCount < 0 || scan.OwnedStaleCount < 0 || scan.UnownedCount < 0 ||
|
||||||
|
scan.OwnedStaleCount+scan.UnownedCount > scan.ObservedCount ||
|
||||||
|
strings.TrimSpace(scan.SafetyReason) == "" || !scan.ExpiresAt.After(scan.CompletedAt) {
|
||||||
|
return errors.New("invalid orphan scan")
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(scan.Findings))
|
||||||
|
staleCount, unownedCount := 0, 0
|
||||||
|
for _, finding := range scan.Findings {
|
||||||
|
if strings.TrimSpace(finding.PathName) == "" {
|
||||||
|
return errors.New("invalid orphan finding path")
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[finding.PathName]; duplicate {
|
||||||
|
return errors.New("duplicate orphan finding path")
|
||||||
|
}
|
||||||
|
seen[finding.PathName] = struct{}{}
|
||||||
|
switch finding.Classification {
|
||||||
|
case OrphanOwnedStale:
|
||||||
|
staleCount++
|
||||||
|
if strings.TrimSpace(finding.DeviceID) == "" {
|
||||||
|
return errors.New("owned stale finding lacks device")
|
||||||
|
}
|
||||||
|
case OrphanUnowned:
|
||||||
|
unownedCount++
|
||||||
|
if finding.DeviceID != "" {
|
||||||
|
return errors.New("unowned finding has device")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid orphan finding classification %q", finding.Classification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if staleCount != scan.OwnedStaleCount || unownedCount != scan.UnownedCount {
|
||||||
|
return errors.New("orphan scan counts do not match findings")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) GetOrphanScan(ctx context.Context, id string) (OrphanScan, error) {
|
||||||
|
var scan OrphanScan
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT
|
||||||
|
id, instance_id, observed_count, owned_stale_count, unowned_count,
|
||||||
|
safety_allowed, safety_reason, completed_at, expires_at
|
||||||
|
FROM sense.orphan_scan_runs WHERE id = $1`, id).Scan(
|
||||||
|
&scan.ID, &scan.InstanceID, &scan.ObservedCount, &scan.OwnedStaleCount,
|
||||||
|
&scan.UnownedCount, &scan.SafetyAllowed, &scan.SafetyReason,
|
||||||
|
&scan.CompletedAt, &scan.ExpiresAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return OrphanScan{}, ErrOrphanScanNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return OrphanScan{}, errors.New("read postgres orphan scan")
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||||
|
f.path_name, f.classification, COALESCE(f.device_id, ''),
|
||||||
|
COALESCE(a.status = 'deleted', false)
|
||||||
|
FROM sense.orphan_scan_findings f
|
||||||
|
LEFT JOIN sense.orphan_cleanup_actions a
|
||||||
|
ON a.scan_id = f.scan_id AND a.path_name = f.path_name
|
||||||
|
WHERE f.scan_id = $1 ORDER BY f.path_name`, id)
|
||||||
|
if err != nil {
|
||||||
|
return OrphanScan{}, errors.New("list postgres orphan scan findings")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
scan.Findings = make([]OrphanFinding, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var finding OrphanFinding
|
||||||
|
if err := rows.Scan(
|
||||||
|
&finding.PathName, &finding.Classification, &finding.DeviceID, &finding.Deleted,
|
||||||
|
); err != nil {
|
||||||
|
return OrphanScan{}, errors.New("scan postgres orphan finding")
|
||||||
|
}
|
||||||
|
scan.Findings = append(scan.Findings, finding)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return OrphanScan{}, errors.New("iterate postgres orphan findings")
|
||||||
|
}
|
||||||
|
return scan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) RecordOrphanCleanup(
|
||||||
|
ctx context.Context,
|
||||||
|
scanID, pathName, actorID, status, errorCode string,
|
||||||
|
now time.Time,
|
||||||
|
) error {
|
||||||
|
if status != "deleted" && status != "failed" {
|
||||||
|
return errors.New("invalid orphan cleanup status")
|
||||||
|
}
|
||||||
|
var storedError any
|
||||||
|
if status == "failed" {
|
||||||
|
if strings.TrimSpace(errorCode) == "" {
|
||||||
|
return errors.New("failed orphan cleanup requires an error code")
|
||||||
|
}
|
||||||
|
storedError = errorCode
|
||||||
|
}
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO sense.orphan_cleanup_actions(
|
||||||
|
scan_id, path_name, classification, actor_id, status, error_code, attempted_at
|
||||||
|
) VALUES ($1,$2,'owned_stale',$3,$4,$5,$6)
|
||||||
|
ON CONFLICT (scan_id, path_name) DO UPDATE SET
|
||||||
|
actor_id = EXCLUDED.actor_id,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
error_code = EXCLUDED.error_code,
|
||||||
|
attempted_at = EXCLUDED.attempted_at
|
||||||
|
WHERE sense.orphan_cleanup_actions.status <> 'deleted'`,
|
||||||
|
scanID, pathName, actorID, status, storedError, now)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("record postgres orphan cleanup result")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,933 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
|
"yovision/sense/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Postgres persists Sense state in the sense schema and consumes only Bell's
|
||||||
|
// versioned quota and Area views. Migrations are installed out of process.
|
||||||
|
type Postgres struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenPostgres(ctx context.Context, dsn string) (*Postgres, error) {
|
||||||
|
configuration, err := pgx.ParseConfig(dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid postgres DSN")
|
||||||
|
}
|
||||||
|
if configuration.RuntimeParams == nil {
|
||||||
|
configuration.RuntimeParams = make(map[string]string)
|
||||||
|
}
|
||||||
|
configuration.RuntimeParams["application_name"] = "yovision-sense"
|
||||||
|
db := stdlib.OpenDB(*configuration)
|
||||||
|
db.SetMaxOpenConns(16)
|
||||||
|
db.SetMaxIdleConns(4)
|
||||||
|
db.SetConnMaxLifetime(30 * time.Minute)
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, errors.New("connect postgres database")
|
||||||
|
}
|
||||||
|
store := &Postgres{db: db}
|
||||||
|
if err := store.verifySchemaAndPrivileges(ctx); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) verifySchemaAndPrivileges(ctx context.Context) error {
|
||||||
|
var version sql.NullInt64
|
||||||
|
if err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT MAX(version) FROM sense.schema_migrations`).Scan(&version); err != nil || !version.Valid || version.Int64 < 5 {
|
||||||
|
return errors.New("postgres sense schema migration v5 is required")
|
||||||
|
}
|
||||||
|
var canReadQuotaView, canWriteQuotaView, canReadSiteSource, canWriteSiteSource bool
|
||||||
|
var canReadAreaView, canWriteAreaView, canReadAreaSource, canWriteAreaSource bool
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||||
|
has_table_privilege(current_user, 'bell.site_quota_v1', 'SELECT'),
|
||||||
|
has_table_privilege(current_user, 'bell.site_quota_v1', 'INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege(current_user, 'bell.sites', 'SELECT'),
|
||||||
|
has_table_privilege(current_user, 'bell.sites', 'INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege(current_user, 'bell.area_policy_v1', 'SELECT'),
|
||||||
|
has_table_privilege(current_user, 'bell.area_policy_v1', 'INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege(current_user, 'bell.areas', 'SELECT'),
|
||||||
|
has_table_privilege(current_user, 'bell.areas', 'INSERT,UPDATE,DELETE')`).
|
||||||
|
Scan(
|
||||||
|
&canReadQuotaView, &canWriteQuotaView, &canReadSiteSource, &canWriteSiteSource,
|
||||||
|
&canReadAreaView, &canWriteAreaView, &canReadAreaSource, &canWriteAreaSource,
|
||||||
|
); err != nil {
|
||||||
|
return errors.New("verify postgres Bell projection privileges")
|
||||||
|
}
|
||||||
|
if !canReadQuotaView || canWriteQuotaView || canReadSiteSource || canWriteSiteSource ||
|
||||||
|
!canReadAreaView || canWriteAreaView || canReadAreaSource || canWriteAreaSource {
|
||||||
|
return errors.New("postgres role violates Bell projection privilege boundary")
|
||||||
|
}
|
||||||
|
var canUseReceipts, canUseOperations, canUseOperationItems bool
|
||||||
|
var publicReceipts, publicOperations, publicOperationItems bool
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||||
|
has_table_privilege(current_user, 'sense.control_idempotency_receipts', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege(current_user, 'sense.batch_operations', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege(current_user, 'sense.batch_operation_items', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege('public', 'sense.control_idempotency_receipts', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege('public', 'sense.batch_operations', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege('public', 'sense.batch_operation_items', 'SELECT,INSERT,UPDATE,DELETE')`).Scan(
|
||||||
|
&canUseReceipts, &canUseOperations, &canUseOperationItems,
|
||||||
|
&publicReceipts, &publicOperations, &publicOperationItems,
|
||||||
|
); err != nil {
|
||||||
|
return errors.New("verify postgres Control API state privileges")
|
||||||
|
}
|
||||||
|
if !canUseReceipts || !canUseOperations || !canUseOperationItems ||
|
||||||
|
publicReceipts || publicOperations || publicOperationItems {
|
||||||
|
return errors.New("postgres role violates Control API state privilege boundary")
|
||||||
|
}
|
||||||
|
var canUseOwnership, canUseLeases, canUseScans, canUseFindings, canUseActions bool
|
||||||
|
var publicOwnership, publicLeases, publicScans, publicFindings, publicActions bool
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||||
|
has_table_privilege(current_user, 'sense.media_path_ownership', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege(current_user, 'sense.operational_leases', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege(current_user, 'sense.orphan_scan_runs', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege(current_user, 'sense.orphan_scan_findings', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege(current_user, 'sense.orphan_cleanup_actions', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege('public', 'sense.media_path_ownership', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege('public', 'sense.operational_leases', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege('public', 'sense.orphan_scan_runs', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege('public', 'sense.orphan_scan_findings', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||||
|
has_table_privilege('public', 'sense.orphan_cleanup_actions', 'SELECT,INSERT,UPDATE,DELETE')`).Scan(
|
||||||
|
&canUseOwnership, &canUseLeases, &canUseScans, &canUseFindings, &canUseActions,
|
||||||
|
&publicOwnership, &publicLeases, &publicScans, &publicFindings, &publicActions,
|
||||||
|
); err != nil {
|
||||||
|
return errors.New("verify postgres reconciliation safety privileges")
|
||||||
|
}
|
||||||
|
if !canUseOwnership || !canUseLeases || !canUseScans || !canUseFindings || !canUseActions ||
|
||||||
|
publicOwnership || publicLeases || publicScans || publicFindings || publicActions {
|
||||||
|
return errors.New("postgres role violates reconciliation safety privilege boundary")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) CreateDevice(ctx context.Context, value device.Device) error {
|
||||||
|
if value.Generation == 0 {
|
||||||
|
value.Generation = 1
|
||||||
|
}
|
||||||
|
if value.ActualState == "" {
|
||||||
|
value.ActualState = device.ActualPending
|
||||||
|
}
|
||||||
|
if value.ResourceVersion == 0 {
|
||||||
|
value.ResourceVersion = 1
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("validate device: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if value.CreatedAt.IsZero() {
|
||||||
|
value.CreatedAt = now
|
||||||
|
}
|
||||||
|
value.UpdatedAt = now
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("begin postgres create device")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
areaVersion, err := checkPostgresAreaPolicy(
|
||||||
|
ctx, tx, value.TenantID, value.SiteID, value.AreaID,
|
||||||
|
value.HasCapability(device.CapabilityVideoCapture), now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var quotaVersion int64
|
||||||
|
if value.ConsumesVideoChannel() {
|
||||||
|
version, quotaErr := checkPostgresVideoQuota(ctx, tx, value.TenantID, value.SiteID, now)
|
||||||
|
if quotaErr != nil {
|
||||||
|
return quotaErr
|
||||||
|
}
|
||||||
|
quotaVersion = version
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `INSERT INTO sense.devices(
|
||||||
|
id, tenant_id, site_id, area_id, serial_number, name, modality,
|
||||||
|
desired_state, actual_state, endpoint_ref, credential_ref, profile_token,
|
||||||
|
path_name, generation, resource_version, quota_source_version, area_policy_source_version,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
|
||||||
|
value.ID, value.TenantID, value.SiteID, value.AreaID, value.SerialNumber, value.Name,
|
||||||
|
value.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
|
||||||
|
value.CredentialRef, value.ProfileToken, value.PathName, value.Generation,
|
||||||
|
value.ResourceVersion, nullableVersion(quotaVersion), areaVersion,
|
||||||
|
value.CreatedAt, value.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("insert postgres device")
|
||||||
|
}
|
||||||
|
for _, capability := range sortedCapabilities(value.Capabilities) {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO sense.device_capabilities(device_id, capability) VALUES ($1, $2)`,
|
||||||
|
value.ID, capability); err != nil {
|
||||||
|
return errors.New("insert postgres device capability")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.reconcile_state(device_id, updated_at)
|
||||||
|
VALUES ($1, $2)`, value.ID, now); err != nil {
|
||||||
|
return errors.New("insert postgres reconcile state")
|
||||||
|
}
|
||||||
|
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||||
|
EventType: "device.created", TenantID: value.TenantID, SiteID: value.SiteID,
|
||||||
|
DeviceID: value.ID, Generation: value.Generation,
|
||||||
|
QuotaSourceVersion: quotaVersion, AreaPolicySourceVersion: areaVersion,
|
||||||
|
OccurredAt: now,
|
||||||
|
Payload: map[string]any{
|
||||||
|
"kind": "device_created", "area_id": value.AreaID,
|
||||||
|
"modality": value.Modality, "capabilities": sortedCapabilities(value.Capabilities),
|
||||||
|
"desired_state": value.DesiredState,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return errors.New("commit postgres create device")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPostgresAreaPolicy(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *sql.Tx,
|
||||||
|
tenantID, siteID, areaID string,
|
||||||
|
imaging bool,
|
||||||
|
now time.Time,
|
||||||
|
) (int64, error) {
|
||||||
|
if strings.TrimSpace(areaID) == "" {
|
||||||
|
return 0, areaPolicyUnavailable()
|
||||||
|
}
|
||||||
|
// Area projection observation is serialized before the Site quota lock.
|
||||||
|
// No admission path acquires these locks in the opposite order.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`SELECT pg_advisory_xact_lock(hashtext($1), hashtext('area:' || $2))`, tenantID, areaID); err != nil {
|
||||||
|
return 0, errors.New("lock postgres Area admission")
|
||||||
|
}
|
||||||
|
var capturePolicy string
|
||||||
|
var sourceVersion int64
|
||||||
|
var sourceUpdatedAt time.Time
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT capture_policy, source_version, source_updated_at
|
||||||
|
FROM bell.area_policy_v1
|
||||||
|
WHERE tenant_id = $1 AND site_id = $2 AND area_id = $3`, tenantID, siteID, areaID).
|
||||||
|
Scan(&capturePolicy, &sourceVersion, &sourceUpdatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, areaPolicyUnavailable()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, areaPolicyUnavailable()
|
||||||
|
}
|
||||||
|
if (capturePolicy != "video_allowed" && capturePolicy != "non_imaging_only") ||
|
||||||
|
sourceVersion < 1 || sourceUpdatedAt.IsZero() {
|
||||||
|
return 0, areaPolicyInvalid()
|
||||||
|
}
|
||||||
|
var previous sql.NullInt64
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT source_version
|
||||||
|
FROM sense.area_policy_projection_state
|
||||||
|
WHERE tenant_id = $1 AND site_id = $2 AND area_id = $3`, tenantID, siteID, areaID).
|
||||||
|
Scan(&previous)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, errors.New("read postgres Area projection state")
|
||||||
|
}
|
||||||
|
if previous.Valid && sourceVersion < previous.Int64 {
|
||||||
|
return 0, areaPolicyInvalid()
|
||||||
|
}
|
||||||
|
if imaging && capturePolicy == "non_imaging_only" {
|
||||||
|
return 0, areaPolicyDenied()
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.area_policy_projection_state(
|
||||||
|
tenant_id, site_id, area_id, source_version, synced_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (tenant_id, site_id, area_id) DO UPDATE SET
|
||||||
|
source_version = EXCLUDED.source_version,
|
||||||
|
synced_at = EXCLUDED.synced_at`, tenantID, siteID, areaID, sourceVersion, now); err != nil {
|
||||||
|
return 0, errors.New("record postgres Area projection state")
|
||||||
|
}
|
||||||
|
return sourceVersion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPostgresVideoQuota(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *sql.Tx,
|
||||||
|
tenantID, siteID string,
|
||||||
|
now time.Time,
|
||||||
|
) (int64, error) {
|
||||||
|
// A transaction-scoped lock shared by all Sense instances makes count +
|
||||||
|
// write atomic per logical site without locking Bell-owned rows.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, tenantID, siteID); err != nil {
|
||||||
|
return 0, errors.New("lock postgres site quota admission")
|
||||||
|
}
|
||||||
|
var limit int
|
||||||
|
var sourceVersion int64
|
||||||
|
var sourceUpdatedAt time.Time
|
||||||
|
err := tx.QueryRowContext(ctx, `SELECT max_video_channels, source_version, source_updated_at
|
||||||
|
FROM bell.site_quota_v1 WHERE tenant_id = $1 AND site_id = $2`, tenantID, siteID).
|
||||||
|
Scan(&limit, &sourceVersion, &sourceUpdatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, projectionUnavailable()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, projectionUnavailable()
|
||||||
|
}
|
||||||
|
if limit < 1 || limit > device.MaximumVideoChannels || sourceVersion < 1 || sourceUpdatedAt.IsZero() {
|
||||||
|
return 0, projectionInvalid()
|
||||||
|
}
|
||||||
|
var previous sql.NullInt64
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT source_version
|
||||||
|
FROM sense.site_quota_projection_state WHERE tenant_id = $1 AND site_id = $2`,
|
||||||
|
tenantID, siteID).Scan(&previous)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, errors.New("read postgres quota projection state")
|
||||||
|
}
|
||||||
|
if previous.Valid && sourceVersion < previous.Int64 {
|
||||||
|
return 0, projectionInvalid()
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.site_quota_projection_state(
|
||||||
|
tenant_id, site_id, source_version, synced_at
|
||||||
|
) VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (tenant_id, site_id) DO UPDATE SET
|
||||||
|
source_version = EXCLUDED.source_version,
|
||||||
|
synced_at = EXCLUDED.synced_at`, tenantID, siteID, sourceVersion, now); err != nil {
|
||||||
|
return 0, errors.New("record postgres quota projection state")
|
||||||
|
}
|
||||||
|
var current int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*)
|
||||||
|
FROM sense.devices d
|
||||||
|
JOIN sense.device_capabilities c ON c.device_id = d.id
|
||||||
|
WHERE d.tenant_id = $1 AND d.site_id = $2
|
||||||
|
AND d.desired_state = 'enabled'
|
||||||
|
AND c.capability = 'video_capture'`, tenantID, siteID).Scan(¤t); err != nil {
|
||||||
|
return 0, errors.New("count postgres site video channels")
|
||||||
|
}
|
||||||
|
if current >= limit {
|
||||||
|
return 0, &device.QuotaExceededError{TenantID: tenantID, SiteID: siteID, Limit: limit}
|
||||||
|
}
|
||||||
|
return sourceVersion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired device.DesiredState) error {
|
||||||
|
if desired != device.DesiredEnabled && desired != device.DesiredDisabled {
|
||||||
|
return fmt.Errorf("invalid desired state %q", desired)
|
||||||
|
}
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("begin postgres desired-state update")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var tenantID, siteID, endpointRef, pathName string
|
||||||
|
var areaID sql.NullString
|
||||||
|
var current device.DesiredState
|
||||||
|
var generation int64
|
||||||
|
var storedQuotaVersion, storedAreaVersion sql.NullInt64
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT tenant_id, site_id, area_id, desired_state,
|
||||||
|
endpoint_ref, path_name, generation, quota_source_version, area_policy_source_version
|
||||||
|
FROM sense.devices WHERE id = $1 FOR UPDATE`, id).
|
||||||
|
Scan(
|
||||||
|
&tenantID, &siteID, &areaID, ¤t, &endpointRef, &pathName,
|
||||||
|
&generation, &storedQuotaVersion, &storedAreaVersion,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("read postgres device desired state")
|
||||||
|
}
|
||||||
|
if current == desired {
|
||||||
|
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||||
|
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
|
||||||
|
DeviceID: id, Generation: generation,
|
||||||
|
QuotaSourceVersion: storedQuotaVersion.Int64,
|
||||||
|
AreaPolicySourceVersion: storedAreaVersion.Int64,
|
||||||
|
OccurredAt: time.Now().UTC(),
|
||||||
|
Payload: map[string]any{
|
||||||
|
"kind": "desired_state_accepted", "previous_desired_state": current,
|
||||||
|
"desired_state": desired, "changed": false,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return errors.New("commit postgres no-op desired-state audit")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var quotaVersion, areaVersion int64
|
||||||
|
if desired == device.DesiredEnabled {
|
||||||
|
var hasVideo bool
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(
|
||||||
|
SELECT 1 FROM sense.device_capabilities
|
||||||
|
WHERE device_id = $1 AND capability = 'video_capture'
|
||||||
|
)`, id).Scan(&hasVideo); err != nil {
|
||||||
|
return errors.New("read postgres video capability")
|
||||||
|
}
|
||||||
|
if hasVideo {
|
||||||
|
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
|
||||||
|
return errors.New("enabled video devices require endpoint ref and path name")
|
||||||
|
}
|
||||||
|
version, areaErr := checkPostgresAreaPolicy(
|
||||||
|
ctx, tx, tenantID, siteID, areaID.String, true, time.Now().UTC(),
|
||||||
|
)
|
||||||
|
if areaErr != nil {
|
||||||
|
return areaErr
|
||||||
|
}
|
||||||
|
areaVersion = version
|
||||||
|
version, quotaErr := checkPostgresVideoQuota(ctx, tx, tenantID, siteID, time.Now().UTC())
|
||||||
|
if quotaErr != nil {
|
||||||
|
return quotaErr
|
||||||
|
}
|
||||||
|
quotaVersion = version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
var updatedQuotaVersion, updatedAreaVersion sql.NullInt64
|
||||||
|
err = tx.QueryRowContext(ctx, `UPDATE sense.devices SET
|
||||||
|
desired_state = $1, actual_state = 'pending', generation = generation + 1,
|
||||||
|
resource_version = resource_version + 1,
|
||||||
|
quota_source_version = COALESCE($2, quota_source_version),
|
||||||
|
area_policy_source_version = COALESCE($3, area_policy_source_version),
|
||||||
|
updated_at = $4
|
||||||
|
WHERE id = $5
|
||||||
|
RETURNING generation, quota_source_version, area_policy_source_version`,
|
||||||
|
desired, nullableVersion(quotaVersion), nullableVersion(areaVersion), now, id).
|
||||||
|
Scan(&generation, &updatedQuotaVersion, &updatedAreaVersion)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("update postgres desired state")
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||||
|
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||||
|
lease_owner = NULL, lease_token = NULL, lease_until = NULL, updated_at = $1
|
||||||
|
WHERE device_id = $2`, now, id); err != nil {
|
||||||
|
return errors.New("reset postgres reconcile state")
|
||||||
|
}
|
||||||
|
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||||
|
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
|
||||||
|
DeviceID: id, Generation: generation,
|
||||||
|
QuotaSourceVersion: updatedQuotaVersion.Int64,
|
||||||
|
AreaPolicySourceVersion: updatedAreaVersion.Int64,
|
||||||
|
OccurredAt: now,
|
||||||
|
Payload: map[string]any{
|
||||||
|
"kind": "desired_state_accepted", "previous_desired_state": current,
|
||||||
|
"desired_state": desired, "changed": true,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return errors.New("commit postgres desired-state update")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) GetDevice(ctx context.Context, id string) (device.Device, error) {
|
||||||
|
value, err := scanPostgresDevice(s.db.QueryRowContext(ctx, postgresDeviceSelect+` WHERE d.id = $1`, id))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return device.Device{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return device.Device{}, errors.New("get postgres device")
|
||||||
|
}
|
||||||
|
value.Capabilities, err = s.capabilities(ctx, value.ID)
|
||||||
|
if err != nil {
|
||||||
|
return device.Device{}, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]ReconcileCandidate, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT `+postgresDeviceColumns+`, r.failure_count, r.next_attempt_at
|
||||||
|
FROM sense.devices d
|
||||||
|
JOIN sense.reconcile_state r ON r.device_id = d.id
|
||||||
|
WHERE EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||||
|
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
||||||
|
AND (r.observed_generation < d.generation
|
||||||
|
OR (d.desired_state = 'enabled' AND r.failure_count > 0))
|
||||||
|
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= $1)
|
||||||
|
ORDER BY d.updated_at, d.id LIMIT $2`, now, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("list postgres due reconcile devices")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
values := make([]ReconcileCandidate, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var candidate ReconcileCandidate
|
||||||
|
var areaID sql.NullString
|
||||||
|
var quotaVersion, areaVersion sql.NullInt64
|
||||||
|
var nextAttempt sql.NullTime
|
||||||
|
if err := rows.Scan(
|
||||||
|
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
|
||||||
|
&areaID, &candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
|
||||||
|
&candidate.Device.DesiredState, &candidate.Device.ActualState,
|
||||||
|
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
|
||||||
|
&candidate.Device.ProfileToken, &candidate.Device.PathName,
|
||||||
|
&candidate.Device.Generation, &candidate.Device.ResourceVersion,
|
||||||
|
"aVersion, &areaVersion,
|
||||||
|
&candidate.Device.CreatedAt, &candidate.Device.UpdatedAt,
|
||||||
|
&candidate.FailureCount, &nextAttempt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, errors.New("scan postgres due reconcile device")
|
||||||
|
}
|
||||||
|
candidate.Device.AreaID = areaID.String
|
||||||
|
candidate.Device.QuotaSourceVersion = quotaVersion.Int64
|
||||||
|
candidate.Device.AreaPolicySourceVersion = areaVersion.Int64
|
||||||
|
if nextAttempt.Valid {
|
||||||
|
value := nextAttempt.Time
|
||||||
|
candidate.NextAttempt = &value
|
||||||
|
}
|
||||||
|
values = append(values, candidate)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, errors.New("iterate postgres due reconcile devices")
|
||||||
|
}
|
||||||
|
for index := range values {
|
||||||
|
values[index].Device.Capabilities, err = s.capabilities(ctx, values[index].Device.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) ClaimDueReconcile(
|
||||||
|
ctx context.Context,
|
||||||
|
claim ReconcileClaim,
|
||||||
|
) ([]ReconcileCandidate, error) {
|
||||||
|
if claim.Limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(claim.Owner) == "" || strings.TrimSpace(claim.Token) == "" ||
|
||||||
|
claim.LeaseDuration <= 0 {
|
||||||
|
return nil, errors.New("invalid postgres reconcile claim")
|
||||||
|
}
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("begin postgres reconcile claim")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
rows, err := tx.QueryContext(ctx, `SELECT `+postgresDeviceColumns+`, r.failure_count, r.next_attempt_at
|
||||||
|
FROM sense.devices d
|
||||||
|
JOIN sense.reconcile_state r ON r.device_id = d.id
|
||||||
|
WHERE EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||||
|
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
||||||
|
AND (r.observed_generation < d.generation
|
||||||
|
OR (d.desired_state = 'enabled' AND r.failure_count > 0))
|
||||||
|
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= clock_timestamp())
|
||||||
|
AND (r.lease_until IS NULL OR r.lease_until <= clock_timestamp())
|
||||||
|
ORDER BY d.updated_at, d.id
|
||||||
|
FOR UPDATE OF r SKIP LOCKED
|
||||||
|
LIMIT $1`, claim.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("select postgres reconcile claims")
|
||||||
|
}
|
||||||
|
values := make([]ReconcileCandidate, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var candidate ReconcileCandidate
|
||||||
|
var areaID sql.NullString
|
||||||
|
var quotaVersion, areaVersion sql.NullInt64
|
||||||
|
var nextAttempt sql.NullTime
|
||||||
|
if err := rows.Scan(
|
||||||
|
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
|
||||||
|
&areaID, &candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
|
||||||
|
&candidate.Device.DesiredState, &candidate.Device.ActualState,
|
||||||
|
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
|
||||||
|
&candidate.Device.ProfileToken, &candidate.Device.PathName,
|
||||||
|
&candidate.Device.Generation, &candidate.Device.ResourceVersion,
|
||||||
|
"aVersion, &areaVersion,
|
||||||
|
&candidate.Device.CreatedAt, &candidate.Device.UpdatedAt,
|
||||||
|
&candidate.FailureCount, &nextAttempt,
|
||||||
|
); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, errors.New("scan postgres reconcile claim")
|
||||||
|
}
|
||||||
|
candidate.Device.AreaID = areaID.String
|
||||||
|
candidate.Device.QuotaSourceVersion = quotaVersion.Int64
|
||||||
|
candidate.Device.AreaPolicySourceVersion = areaVersion.Int64
|
||||||
|
if nextAttempt.Valid {
|
||||||
|
value := nextAttempt.Time
|
||||||
|
candidate.NextAttempt = &value
|
||||||
|
}
|
||||||
|
values = append(values, candidate)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, errors.New("close postgres reconcile claims")
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, errors.New("iterate postgres reconcile claims")
|
||||||
|
}
|
||||||
|
for _, candidate := range values {
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||||
|
lease_owner = $1, lease_token = $2,
|
||||||
|
lease_until = clock_timestamp() + ($3 * interval '1 second'),
|
||||||
|
updated_at = clock_timestamp()
|
||||||
|
WHERE device_id = $4`,
|
||||||
|
claim.Owner, claim.Token, claim.LeaseDuration.Seconds(), candidate.Device.ID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, errors.New("persist postgres reconcile claim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, errors.New("commit postgres reconcile claim")
|
||||||
|
}
|
||||||
|
for index := range values {
|
||||||
|
values[index].Device.Capabilities, err = s.capabilities(ctx, values[index].Device.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) RenewReconcileLease(
|
||||||
|
ctx context.Context,
|
||||||
|
id, owner, token string,
|
||||||
|
now time.Time,
|
||||||
|
duration time.Duration,
|
||||||
|
) (bool, error) {
|
||||||
|
if strings.TrimSpace(owner) == "" || strings.TrimSpace(token) == "" || duration <= 0 {
|
||||||
|
return false, errors.New("invalid postgres reconcile lease renewal")
|
||||||
|
}
|
||||||
|
result, err := s.db.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||||
|
lease_until = clock_timestamp() + ($1 * interval '1 second'),
|
||||||
|
updated_at = clock_timestamp()
|
||||||
|
WHERE device_id = $2 AND lease_owner = $3 AND lease_token = $4
|
||||||
|
AND lease_until > clock_timestamp()`, duration.Seconds(), id, owner, token)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.New("renew postgres reconcile lease")
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.New("read postgres reconcile lease renewal")
|
||||||
|
}
|
||||||
|
return affected == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) CompleteReconcile(
|
||||||
|
ctx context.Context,
|
||||||
|
id string,
|
||||||
|
generation int64,
|
||||||
|
owner, token string,
|
||||||
|
now time.Time,
|
||||||
|
) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("begin postgres fenced reconciliation completion")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||||
|
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||||
|
observed_generation = $1, lease_owner = NULL, lease_token = NULL,
|
||||||
|
lease_until = NULL, updated_at = $2
|
||||||
|
WHERE device_id = $3 AND lease_owner = $4 AND lease_token = $5
|
||||||
|
AND lease_until > clock_timestamp()`, generation, now, id, owner, token)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("complete postgres fenced reconciliation")
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("read postgres fenced reconciliation completion")
|
||||||
|
}
|
||||||
|
if affected != 1 {
|
||||||
|
return ErrReconcileLeaseLost
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.media_path_ownership(
|
||||||
|
path_name, device_id, tenant_id, site_id, first_claimed_at, last_confirmed_at
|
||||||
|
)
|
||||||
|
SELECT path_name, id, tenant_id, site_id, $1, $1
|
||||||
|
FROM sense.devices
|
||||||
|
WHERE id = $2 AND desired_state = 'enabled' AND btrim(path_name) <> ''
|
||||||
|
ON CONFLICT (path_name) DO UPDATE SET
|
||||||
|
device_id = EXCLUDED.device_id,
|
||||||
|
tenant_id = EXCLUDED.tenant_id,
|
||||||
|
site_id = EXCLUDED.site_id,
|
||||||
|
last_confirmed_at = EXCLUDED.last_confirmed_at`, now, id); err != nil {
|
||||||
|
return errors.New("record postgres MediaMTX path ownership")
|
||||||
|
}
|
||||||
|
result, err = tx.ExecContext(ctx, `UPDATE sense.devices
|
||||||
|
SET actual_state = CASE WHEN desired_state = 'disabled' THEN 'offline' ELSE 'pending' END,
|
||||||
|
updated_at = $1 WHERE id = $2`, now, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("mark postgres fenced device state")
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return errors.New("commit postgres fenced reconciliation completion")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) FailReconcile(
|
||||||
|
ctx context.Context,
|
||||||
|
id string,
|
||||||
|
failureCount int,
|
||||||
|
nextAttempt time.Time,
|
||||||
|
errorCode, owner, token string,
|
||||||
|
now time.Time,
|
||||||
|
) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("begin postgres fenced reconciliation failure")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||||
|
failure_count = $1, next_attempt_at = $2, last_error_code = $3,
|
||||||
|
lease_owner = NULL, lease_token = NULL, lease_until = NULL, updated_at = $4
|
||||||
|
WHERE device_id = $5 AND lease_owner = $6 AND lease_token = $7
|
||||||
|
AND lease_until > clock_timestamp()`, failureCount, nextAttempt, errorCode, now, id, owner, token)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("persist postgres fenced reconciliation failure")
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("read postgres fenced reconciliation failure")
|
||||||
|
}
|
||||||
|
if affected != 1 {
|
||||||
|
return ErrReconcileLeaseLost
|
||||||
|
}
|
||||||
|
result, err = tx.ExecContext(ctx, `UPDATE sense.devices
|
||||||
|
SET actual_state = 'failed', updated_at = $1 WHERE id = $2`, now, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("mark postgres fenced failed device")
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return errors.New("commit postgres fenced reconciliation failure")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, postgresDeviceSelect+`
|
||||||
|
WHERE d.desired_state = 'enabled'
|
||||||
|
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||||
|
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
||||||
|
ORDER BY d.id LIMIT $1`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("list postgres enabled video devices")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
values := make([]device.Device, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
value, scanErr := scanPostgresDevice(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, errors.New("scan postgres enabled video device")
|
||||||
|
}
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, errors.New("iterate postgres enabled video devices")
|
||||||
|
}
|
||||||
|
for index := range values {
|
||||||
|
values[index].Capabilities, err = s.capabilities(ctx, values[index].ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("begin postgres reconciled update")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||||
|
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||||
|
observed_generation = $1, updated_at = $2 WHERE device_id = $3`, generation, now, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("mark postgres device reconciled")
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE sense.devices
|
||||||
|
SET actual_state = CASE WHEN desired_state = 'disabled' THEN 'offline' ELSE 'pending' END,
|
||||||
|
updated_at = $1 WHERE id = $2`, now, id); err != nil {
|
||||||
|
return errors.New("mark postgres reconciled device pending")
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return errors.New("commit postgres reconciled update")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) MarkReconcileFailure(
|
||||||
|
ctx context.Context,
|
||||||
|
id string,
|
||||||
|
failureCount int,
|
||||||
|
nextAttempt time.Time,
|
||||||
|
errorCode string,
|
||||||
|
now time.Time,
|
||||||
|
) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("begin postgres reconcile failure update")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||||
|
failure_count = $1, next_attempt_at = $2, last_error_code = $3, updated_at = $4
|
||||||
|
WHERE device_id = $5`, failureCount, nextAttempt, errorCode, now, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("mark postgres reconcile failure")
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE sense.devices
|
||||||
|
SET actual_state = 'failed', updated_at = $1 WHERE id = $2`, now, id); err != nil {
|
||||||
|
return errors.New("mark postgres failed device state")
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return errors.New("commit postgres reconcile failure")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error {
|
||||||
|
if state != device.ActualPending && state != device.ActualOnline &&
|
||||||
|
state != device.ActualOffline && state != device.ActualFailed {
|
||||||
|
return fmt.Errorf("invalid actual state %q", state)
|
||||||
|
}
|
||||||
|
result, err := s.db.ExecContext(ctx, `UPDATE sense.devices
|
||||||
|
SET actual_state = $1, updated_at = $2 WHERE id = $3`, state, now, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("update postgres actual state")
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) RequestReconcile(ctx context.Context, id string, now time.Time) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `UPDATE sense.reconcile_state
|
||||||
|
SET observed_generation = 0, updated_at = $1 WHERE device_id = $2`, now, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("request postgres device reconciliation")
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) ConvergenceSnapshot(ctx context.Context) (ConvergenceSnapshot, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||||
|
d.id, d.path_name, d.desired_state, d.actual_state, d.generation,
|
||||||
|
r.observed_generation, r.failure_count, r.next_attempt_at, r.last_error_code
|
||||||
|
FROM sense.devices d
|
||||||
|
JOIN sense.reconcile_state r ON r.device_id = d.id
|
||||||
|
WHERE d.desired_state = 'enabled'
|
||||||
|
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||||
|
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
||||||
|
ORDER BY d.id`)
|
||||||
|
if err != nil {
|
||||||
|
return ConvergenceSnapshot{}, errors.New("query postgres convergence snapshot")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
snapshot := ConvergenceSnapshot{Devices: make([]DeviceConvergence, 0)}
|
||||||
|
for rows.Next() {
|
||||||
|
var value DeviceConvergence
|
||||||
|
var nextAttempt sql.NullTime
|
||||||
|
var lastError sql.NullString
|
||||||
|
if err := rows.Scan(
|
||||||
|
&value.ID, &value.PathName, &value.DesiredState, &value.ActualState,
|
||||||
|
&value.Generation, &value.ObservedGeneration, &value.FailureCount,
|
||||||
|
&nextAttempt, &lastError,
|
||||||
|
); err != nil {
|
||||||
|
return ConvergenceSnapshot{}, errors.New("scan postgres convergence snapshot")
|
||||||
|
}
|
||||||
|
if nextAttempt.Valid {
|
||||||
|
point := nextAttempt.Time
|
||||||
|
value.NextAttemptAt = &point
|
||||||
|
}
|
||||||
|
if lastError.Valid {
|
||||||
|
value.LastErrorCode = lastError.String
|
||||||
|
}
|
||||||
|
value.Converged = value.ObservedGeneration == value.Generation &&
|
||||||
|
value.FailureCount == 0 && value.ActualState == device.ActualOnline
|
||||||
|
if !value.Converged {
|
||||||
|
snapshot.Unconverged++
|
||||||
|
}
|
||||||
|
snapshot.Devices = append(snapshot.Devices, value)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return ConvergenceSnapshot{}, errors.New("iterate postgres convergence snapshot")
|
||||||
|
}
|
||||||
|
snapshot.Total = len(snapshot.Devices)
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const postgresDeviceColumns = `d.id, d.tenant_id, d.site_id, d.area_id, d.serial_number, d.name, d.modality,
|
||||||
|
d.desired_state, d.actual_state, d.endpoint_ref, d.credential_ref, d.profile_token,
|
||||||
|
d.path_name, d.generation, d.resource_version, d.quota_source_version, d.area_policy_source_version,
|
||||||
|
d.created_at, d.updated_at`
|
||||||
|
|
||||||
|
const postgresDeviceSelect = `SELECT ` + postgresDeviceColumns + ` FROM sense.devices d`
|
||||||
|
|
||||||
|
func scanPostgresDevice(row scanner) (device.Device, error) {
|
||||||
|
var value device.Device
|
||||||
|
var areaID sql.NullString
|
||||||
|
var quotaVersion, areaVersion sql.NullInt64
|
||||||
|
err := row.Scan(
|
||||||
|
&value.ID, &value.TenantID, &value.SiteID, &areaID, &value.SerialNumber,
|
||||||
|
&value.Name, &value.Modality, &value.DesiredState, &value.ActualState,
|
||||||
|
&value.EndpointRef, &value.CredentialRef, &value.ProfileToken, &value.PathName,
|
||||||
|
&value.Generation, &value.ResourceVersion, "aVersion, &areaVersion,
|
||||||
|
&value.CreatedAt, &value.UpdatedAt,
|
||||||
|
)
|
||||||
|
value.AreaID = areaID.String
|
||||||
|
value.QuotaSourceVersion = quotaVersion.Int64
|
||||||
|
value.AreaPolicySourceVersion = areaVersion.Int64
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Postgres) capabilities(ctx context.Context, id string) ([]device.Capability, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT capability
|
||||||
|
FROM sense.device_capabilities WHERE device_id = $1 ORDER BY capability`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("list postgres device capabilities")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
values := make([]device.Capability, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var value device.Capability
|
||||||
|
if err := rows.Scan(&value); err != nil {
|
||||||
|
return nil, errors.New("scan postgres device capability")
|
||||||
|
}
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, errors.New("iterate postgres device capabilities")
|
||||||
|
}
|
||||||
|
sort.Slice(values, func(i, j int) bool { return values[i] < values[j] })
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DriverSQLite = "sqlite"
|
||||||
|
DriverPostgres = "postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrQuotaProjectionUnavailable = errors.New("quota projection unavailable")
|
||||||
|
ErrQuotaProjectionInvalid = errors.New("quota projection invalid")
|
||||||
|
ErrAreaPolicyUnavailable = errors.New("area policy unavailable")
|
||||||
|
ErrAreaPolicyInvalid = errors.New("area policy invalid")
|
||||||
|
ErrAreaPolicyDenied = errors.New("area policy denies imaging device")
|
||||||
|
ErrReconcileLeaseLost = errors.New("reconcile lease lost")
|
||||||
|
ErrOperationalLeaseLost = errors.New("operational lease lost")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository is the storage boundary used by the Sense process. SQLite stays
|
||||||
|
// available for M1 development; PostgreSQL implements the M2 production path.
|
||||||
|
type Repository interface {
|
||||||
|
Close() error
|
||||||
|
CreateDevice(context.Context, device.Device) error
|
||||||
|
SetDesiredState(context.Context, string, device.DesiredState) error
|
||||||
|
GetDevice(context.Context, string) (device.Device, error)
|
||||||
|
ListDueReconcile(context.Context, time.Time, int) ([]ReconcileCandidate, error)
|
||||||
|
ClaimDueReconcile(context.Context, ReconcileClaim) ([]ReconcileCandidate, error)
|
||||||
|
RenewReconcileLease(context.Context, string, string, string, time.Time, time.Duration) (bool, error)
|
||||||
|
CompleteReconcile(context.Context, string, int64, string, string, time.Time) error
|
||||||
|
FailReconcile(context.Context, string, int, time.Time, string, string, string, time.Time) error
|
||||||
|
ListEnabledVideoDevices(context.Context, int) ([]device.Device, error)
|
||||||
|
MarkReconciled(context.Context, string, int64, time.Time) error
|
||||||
|
MarkReconcileFailure(context.Context, string, int, time.Time, string, time.Time) error
|
||||||
|
UpdateActualState(context.Context, string, device.ActualState, time.Time) error
|
||||||
|
RequestReconcile(context.Context, string, time.Time) error
|
||||||
|
ConvergenceSnapshot(context.Context) (ConvergenceSnapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconcileClaim identifies one short-lived batch claim. Token is unique per
|
||||||
|
// run and fences a worker whose lease expired and was acquired by another
|
||||||
|
// process. SQLite accepts the shape but remains explicitly single-process.
|
||||||
|
type ReconcileClaim struct {
|
||||||
|
Owner string
|
||||||
|
Token string
|
||||||
|
Now time.Time
|
||||||
|
LeaseDuration time.Duration
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenRepository(ctx context.Context, driver, dsn string) (Repository, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(driver)) {
|
||||||
|
case "", DriverSQLite:
|
||||||
|
return OpenSQLite(ctx, dsn)
|
||||||
|
case DriverPostgres:
|
||||||
|
return OpenPostgres(ctx, dsn)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported database driver %q", driver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type quotaProjectionError struct {
|
||||||
|
kind error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *quotaProjectionError) Error() string { return e.kind.Error() }
|
||||||
|
func (e *quotaProjectionError) Unwrap() error { return e.kind }
|
||||||
|
|
||||||
|
func projectionUnavailable() error {
|
||||||
|
return "aProjectionError{kind: ErrQuotaProjectionUnavailable}
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectionInvalid() error {
|
||||||
|
return "aProjectionError{kind: ErrQuotaProjectionInvalid}
|
||||||
|
}
|
||||||
|
|
||||||
|
type areaPolicyError struct {
|
||||||
|
kind error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *areaPolicyError) Error() string { return e.kind.Error() }
|
||||||
|
func (e *areaPolicyError) Unwrap() error { return e.kind }
|
||||||
|
|
||||||
|
func areaPolicyUnavailable() error {
|
||||||
|
return &areaPolicyError{kind: ErrAreaPolicyUnavailable}
|
||||||
|
}
|
||||||
|
|
||||||
|
func areaPolicyInvalid() error {
|
||||||
|
return &areaPolicyError{kind: ErrAreaPolicyInvalid}
|
||||||
|
}
|
||||||
|
|
||||||
|
func areaPolicyDenied() error {
|
||||||
|
return &areaPolicyError{kind: ErrAreaPolicyDenied}
|
||||||
|
}
|
||||||
@@ -0,0 +1,724 @@
|
|||||||
|
// Package store persists the Sense desired state and reconciliation progress.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"yovision/sense/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("store record not found")
|
||||||
|
|
||||||
|
type ReconcileCandidate struct {
|
||||||
|
Device device.Device
|
||||||
|
FailureCount int
|
||||||
|
NextAttempt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceConvergence struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
PathName string `json:"path_name"`
|
||||||
|
DesiredState device.DesiredState `json:"desired_state"`
|
||||||
|
ActualState device.ActualState `json:"actual_state"`
|
||||||
|
Generation int64 `json:"generation"`
|
||||||
|
ObservedGeneration int64 `json:"observed_generation"`
|
||||||
|
FailureCount int `json:"failure_count"`
|
||||||
|
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
|
||||||
|
LastErrorCode string `json:"last_error_code,omitempty"`
|
||||||
|
Converged bool `json:"converged"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConvergenceSnapshot struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Unconverged int `json:"unconverged"`
|
||||||
|
Devices []DeviceConvergence `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SQLite struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenSQLite(ctx context.Context, dsn string) (*SQLite, error) {
|
||||||
|
if err := ensureSQLiteDirectory(dsn); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
}
|
||||||
|
// M1 runs one writer per edge instance. A single connection also gives
|
||||||
|
// deterministic quota transactions and avoids :memory: connection splits.
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||||
|
}
|
||||||
|
store := &SQLite{db: db}
|
||||||
|
if err := store.Migrate(ctx); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSQLiteDirectory(dsn string) error {
|
||||||
|
if !strings.HasPrefix(dsn, "file:") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
path := strings.TrimPrefix(dsn, "file:")
|
||||||
|
path = strings.SplitN(path, "?", 2)[0]
|
||||||
|
if path == "" || path == ":memory:" || strings.HasPrefix(path, ":memory:") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
directory := filepath.Dir(filepath.FromSlash(path))
|
||||||
|
if directory == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(directory, 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create sqlite directory: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) Migrate(ctx context.Context) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
|
||||||
|
return fmt.Errorf("enable sqlite foreign keys: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := s.db.ExecContext(ctx, `PRAGMA busy_timeout = 5000`); err != nil {
|
||||||
|
return fmt.Errorf("configure sqlite busy timeout: %w", err)
|
||||||
|
}
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin migration: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for _, statement := range migrationStatements {
|
||||||
|
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||||
|
return fmt.Errorf("apply sqlite migration: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO sense_schema_migrations(version, applied_at)
|
||||||
|
VALUES (1, ?)
|
||||||
|
ON CONFLICT(version) DO NOTHING`, formatTime(time.Now())); err != nil {
|
||||||
|
return fmt.Errorf("record sqlite migration: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit sqlite migration: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var migrationStatements = []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS sense_schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS sense_sites (
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
max_video_channels INTEGER NOT NULL DEFAULT 16 CHECK (max_video_channels BETWEEN 1 AND 128),
|
||||||
|
PRIMARY KEY (tenant_id, id)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS sense_devices (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
site_id TEXT NOT NULL,
|
||||||
|
serial_number TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
modality TEXT NOT NULL,
|
||||||
|
desired_state TEXT NOT NULL CHECK (desired_state IN ('disabled', 'enabled')),
|
||||||
|
actual_state TEXT NOT NULL CHECK (actual_state IN ('pending', 'online', 'offline', 'failed')),
|
||||||
|
endpoint_ref TEXT NOT NULL DEFAULT '',
|
||||||
|
credential_ref TEXT NOT NULL DEFAULT '',
|
||||||
|
path_name TEXT NOT NULL DEFAULT '',
|
||||||
|
generation INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (tenant_id, site_id, serial_number),
|
||||||
|
FOREIGN KEY (tenant_id, site_id) REFERENCES sense_sites(tenant_id, id)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS sense_device_capabilities (
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
capability TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (device_id, capability),
|
||||||
|
FOREIGN KEY (device_id) REFERENCES sense_devices(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS sense_reconcile_state (
|
||||||
|
device_id TEXT PRIMARY KEY,
|
||||||
|
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at TEXT,
|
||||||
|
last_error_code TEXT,
|
||||||
|
observed_generation INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (device_id) REFERENCES sense_devices(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS sense_devices_site_state_idx
|
||||||
|
ON sense_devices(tenant_id, site_id, desired_state)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS sense_devices_path_name_idx
|
||||||
|
ON sense_devices(path_name) WHERE path_name <> ''`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS sense_reconcile_due_idx
|
||||||
|
ON sense_reconcile_state(next_attempt_at)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) EnsureSite(ctx context.Context, site device.Site) error {
|
||||||
|
site.ApplyDefaults()
|
||||||
|
if err := site.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("validate site: %w", err)
|
||||||
|
}
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO sense_sites(tenant_id, id, name, max_video_channels)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(tenant_id, id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
max_video_channels = excluded.max_video_channels`,
|
||||||
|
site.TenantID, site.ID, site.Name, site.MaxVideoChannels)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ensure site: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) CreateDevice(ctx context.Context, value device.Device) error {
|
||||||
|
if value.Generation == 0 {
|
||||||
|
value.Generation = 1
|
||||||
|
}
|
||||||
|
if value.ActualState == "" {
|
||||||
|
value.ActualState = device.ActualPending
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("validate device: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if value.CreatedAt.IsZero() {
|
||||||
|
value.CreatedAt = now
|
||||||
|
}
|
||||||
|
value.UpdatedAt = now
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin create device: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if value.ConsumesVideoChannel() {
|
||||||
|
if err := checkVideoQuota(ctx, tx, value.TenantID, value.SiteID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO sense_devices(
|
||||||
|
id, tenant_id, site_id, serial_number, name, modality,
|
||||||
|
desired_state, actual_state, endpoint_ref, credential_ref,
|
||||||
|
path_name, generation, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
value.ID, value.TenantID, value.SiteID, value.SerialNumber, value.Name,
|
||||||
|
value.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
|
||||||
|
value.CredentialRef, value.PathName, value.Generation,
|
||||||
|
formatTime(value.CreatedAt), formatTime(value.UpdatedAt))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert device: %w", err)
|
||||||
|
}
|
||||||
|
for _, capability := range sortedCapabilities(value.Capabilities) {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO sense_device_capabilities(device_id, capability) VALUES (?, ?)`,
|
||||||
|
value.ID, capability); err != nil {
|
||||||
|
return fmt.Errorf("insert device capability: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO sense_reconcile_state(device_id, updated_at)
|
||||||
|
VALUES (?, ?)`, value.ID, formatTime(now)); err != nil {
|
||||||
|
return fmt.Errorf("insert reconcile state: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit create device: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkVideoQuota(ctx context.Context, tx *sql.Tx, tenantID, siteID string) error {
|
||||||
|
var limit int
|
||||||
|
err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT max_video_channels FROM sense_sites WHERE tenant_id = ? AND id = ?`,
|
||||||
|
tenantID, siteID).Scan(&limit)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fmt.Errorf("site %s/%s: %w", tenantID, siteID, ErrNotFound)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read site quota: %w", err)
|
||||||
|
}
|
||||||
|
var current int
|
||||||
|
err = tx.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sense_devices d
|
||||||
|
JOIN sense_device_capabilities c ON c.device_id = d.id
|
||||||
|
WHERE d.tenant_id = ? AND d.site_id = ?
|
||||||
|
AND d.desired_state = 'enabled'
|
||||||
|
AND c.capability = 'video_capture'`, tenantID, siteID).Scan(¤t)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("count site video channels: %w", err)
|
||||||
|
}
|
||||||
|
if current >= limit {
|
||||||
|
return &device.QuotaExceededError{TenantID: tenantID, SiteID: siteID, Limit: limit}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) SetDesiredState(ctx context.Context, id string, desired device.DesiredState) error {
|
||||||
|
if desired != device.DesiredEnabled && desired != device.DesiredDisabled {
|
||||||
|
return fmt.Errorf("invalid desired state %q", desired)
|
||||||
|
}
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin desired-state update: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var tenantID, siteID, endpointRef, pathName string
|
||||||
|
var current device.DesiredState
|
||||||
|
err = tx.QueryRowContext(ctx,
|
||||||
|
`SELECT tenant_id, site_id, desired_state, endpoint_ref, path_name FROM sense_devices WHERE id = ?`, id).
|
||||||
|
Scan(&tenantID, &siteID, ¤t, &endpointRef, &pathName)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read device desired state: %w", err)
|
||||||
|
}
|
||||||
|
if current == desired {
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
if desired == device.DesiredEnabled {
|
||||||
|
var videoCapability int
|
||||||
|
if err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM sense_device_capabilities
|
||||||
|
WHERE device_id = ? AND capability = 'video_capture'`, id).Scan(&videoCapability); err != nil {
|
||||||
|
return fmt.Errorf("read video capability: %w", err)
|
||||||
|
}
|
||||||
|
if videoCapability > 0 {
|
||||||
|
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
|
||||||
|
return fmt.Errorf("enabled video devices require endpoint ref and path name")
|
||||||
|
}
|
||||||
|
if err := checkVideoQuota(ctx, tx, tenantID, siteID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE sense_devices
|
||||||
|
SET desired_state = ?, actual_state = 'pending', generation = generation + 1, updated_at = ?
|
||||||
|
WHERE id = ?`, desired, formatTime(time.Now()), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update desired state: %w", err)
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE sense_reconcile_state
|
||||||
|
SET failure_count = 0, next_attempt_at = NULL, last_error_code = NULL, updated_at = ?
|
||||||
|
WHERE device_id = ?`, formatTime(time.Now()), id); err != nil {
|
||||||
|
return fmt.Errorf("reset reconcile state: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit desired-state update: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) GetDevice(ctx context.Context, id string) (device.Device, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, deviceSelect+` WHERE d.id = ?`, id)
|
||||||
|
value, err := scanDevice(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return device.Device{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return device.Device{}, fmt.Errorf("get device: %w", err)
|
||||||
|
}
|
||||||
|
capabilities, err := s.capabilities(ctx, value.ID)
|
||||||
|
if err != nil {
|
||||||
|
return device.Device{}, err
|
||||||
|
}
|
||||||
|
value.Capabilities = capabilities
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]ReconcileCandidate, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT `+deviceColumns+`, r.failure_count, r.next_attempt_at
|
||||||
|
FROM sense_devices d
|
||||||
|
JOIN sense_reconcile_state r ON r.device_id = d.id
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM sense_device_capabilities c
|
||||||
|
WHERE c.device_id = d.id AND c.capability = 'video_capture'
|
||||||
|
)
|
||||||
|
AND (r.observed_generation < d.generation
|
||||||
|
OR (d.desired_state = 'enabled' AND r.failure_count > 0))
|
||||||
|
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= ?)
|
||||||
|
ORDER BY d.updated_at, d.id
|
||||||
|
LIMIT ?`, formatTime(now), limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list due reconcile devices: %w", err)
|
||||||
|
}
|
||||||
|
candidates := make([]ReconcileCandidate, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var candidate ReconcileCandidate
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
var nextAttempt sql.NullString
|
||||||
|
if err := rows.Scan(
|
||||||
|
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
|
||||||
|
&candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
|
||||||
|
&candidate.Device.DesiredState, &candidate.Device.ActualState,
|
||||||
|
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
|
||||||
|
&candidate.Device.PathName, &candidate.Device.Generation,
|
||||||
|
&createdAt, &updatedAt, &candidate.FailureCount, &nextAttempt,
|
||||||
|
); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("scan due reconcile device: %w", err)
|
||||||
|
}
|
||||||
|
candidate.Device.CreatedAt, err = parseTime(createdAt)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
candidate.Device.UpdatedAt, err = parseTime(updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if nextAttempt.Valid {
|
||||||
|
value, parseErr := parseTime(nextAttempt.String)
|
||||||
|
if parseErr != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, parseErr
|
||||||
|
}
|
||||||
|
candidate.NextAttempt = &value
|
||||||
|
}
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("close due reconcile rows: %w", err)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate due reconcile devices: %w", err)
|
||||||
|
}
|
||||||
|
for index := range candidates {
|
||||||
|
capabilities, err := s.capabilities(ctx, candidates[index].Device.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
candidates[index].Device.Capabilities = capabilities
|
||||||
|
}
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) ClaimDueReconcile(ctx context.Context, claim ReconcileClaim) ([]ReconcileCandidate, error) {
|
||||||
|
// SQLite is retained for one-process M1 development. It deliberately does
|
||||||
|
// not claim cross-process leases; PostgreSQL is the production M2 boundary.
|
||||||
|
return s.ListDueReconcile(ctx, claim.Now, claim.Limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) RenewReconcileLease(
|
||||||
|
ctx context.Context,
|
||||||
|
_, _, _ string,
|
||||||
|
_ time.Time,
|
||||||
|
_ time.Duration,
|
||||||
|
) (bool, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, deviceSelect+`
|
||||||
|
WHERE d.desired_state = 'enabled'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM sense_device_capabilities c
|
||||||
|
WHERE c.device_id = d.id AND c.capability = 'video_capture'
|
||||||
|
)
|
||||||
|
ORDER BY d.id LIMIT ?`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list enabled video devices: %w", err)
|
||||||
|
}
|
||||||
|
values := make([]device.Device, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
value, err := scanDevice(rows)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("scan enabled video device: %w", err)
|
||||||
|
}
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("close enabled video rows: %w", err)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate enabled video devices: %w", err)
|
||||||
|
}
|
||||||
|
for index := range values {
|
||||||
|
capabilities, err := s.capabilities(ctx, values[index].ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values[index].Capabilities = capabilities
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin reconciled update: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE sense_reconcile_state
|
||||||
|
SET failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||||
|
observed_generation = ?, updated_at = ?
|
||||||
|
WHERE device_id = ?`, generation, formatTime(now), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark device reconciled: %w", err)
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE sense_devices SET
|
||||||
|
actual_state = CASE WHEN desired_state = 'disabled' THEN 'offline' ELSE 'pending' END,
|
||||||
|
updated_at = ? WHERE id = ?`,
|
||||||
|
formatTime(now), id); err != nil {
|
||||||
|
return fmt.Errorf("mark reconciled device pending: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit reconciled update: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) CompleteReconcile(
|
||||||
|
ctx context.Context,
|
||||||
|
id string,
|
||||||
|
generation int64,
|
||||||
|
_, _ string,
|
||||||
|
now time.Time,
|
||||||
|
) error {
|
||||||
|
return s.MarkReconciled(ctx, id, generation, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) MarkReconcileFailure(ctx context.Context, id string, failureCount int, nextAttempt time.Time, errorCode string, now time.Time) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin reconcile failure update: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE sense_reconcile_state
|
||||||
|
SET failure_count = ?, next_attempt_at = ?, last_error_code = ?, updated_at = ?
|
||||||
|
WHERE device_id = ?`, failureCount, formatTime(nextAttempt), errorCode, formatTime(now), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark reconcile failure: %w", err)
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE sense_devices SET actual_state = 'failed', updated_at = ? WHERE id = ?`,
|
||||||
|
formatTime(now), id); err != nil {
|
||||||
|
return fmt.Errorf("mark failed device state: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit reconcile failure update: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) FailReconcile(
|
||||||
|
ctx context.Context,
|
||||||
|
id string,
|
||||||
|
failureCount int,
|
||||||
|
nextAttempt time.Time,
|
||||||
|
errorCode, _, _ string,
|
||||||
|
now time.Time,
|
||||||
|
) error {
|
||||||
|
return s.MarkReconcileFailure(ctx, id, failureCount, nextAttempt, errorCode, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error {
|
||||||
|
if state != device.ActualPending && state != device.ActualOnline && state != device.ActualOffline && state != device.ActualFailed {
|
||||||
|
return fmt.Errorf("invalid actual state %q", state)
|
||||||
|
}
|
||||||
|
result, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE sense_devices SET actual_state = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
state, formatTime(now), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update actual state: %w", err)
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestReconcile invalidates the observed generation without changing the
|
||||||
|
// desired state or retry backoff. Runtime probes use it when MediaMTX loses a
|
||||||
|
// configured path, including after a MediaMTX process restart.
|
||||||
|
func (s *SQLite) RequestReconcile(ctx context.Context, id string, now time.Time) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `
|
||||||
|
UPDATE sense_reconcile_state
|
||||||
|
SET observed_generation = 0, updated_at = ?
|
||||||
|
WHERE device_id = ?`, formatTime(now), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("request device reconciliation: %w", err)
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvergenceSnapshot returns only identifiers, state and counters. Endpoint
|
||||||
|
// and credential references are deliberately excluded from diagnostics.
|
||||||
|
func (s *SQLite) ConvergenceSnapshot(ctx context.Context) (ConvergenceSnapshot, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT d.id, d.path_name, d.desired_state, d.actual_state, d.generation,
|
||||||
|
r.observed_generation, r.failure_count, r.next_attempt_at, r.last_error_code
|
||||||
|
FROM sense_devices d
|
||||||
|
JOIN sense_reconcile_state r ON r.device_id = d.id
|
||||||
|
WHERE d.desired_state = 'enabled'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM sense_device_capabilities c
|
||||||
|
WHERE c.device_id = d.id AND c.capability = 'video_capture'
|
||||||
|
)
|
||||||
|
ORDER BY d.id`)
|
||||||
|
if err != nil {
|
||||||
|
return ConvergenceSnapshot{}, fmt.Errorf("query convergence snapshot: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
snapshot := ConvergenceSnapshot{Devices: make([]DeviceConvergence, 0)}
|
||||||
|
for rows.Next() {
|
||||||
|
var value DeviceConvergence
|
||||||
|
var nextAttempt, lastError sql.NullString
|
||||||
|
if err := rows.Scan(
|
||||||
|
&value.ID, &value.PathName, &value.DesiredState, &value.ActualState,
|
||||||
|
&value.Generation, &value.ObservedGeneration, &value.FailureCount,
|
||||||
|
&nextAttempt, &lastError,
|
||||||
|
); err != nil {
|
||||||
|
return ConvergenceSnapshot{}, fmt.Errorf("scan convergence snapshot: %w", err)
|
||||||
|
}
|
||||||
|
if nextAttempt.Valid {
|
||||||
|
parsed, parseErr := parseTime(nextAttempt.String)
|
||||||
|
if parseErr != nil {
|
||||||
|
return ConvergenceSnapshot{}, parseErr
|
||||||
|
}
|
||||||
|
value.NextAttemptAt = &parsed
|
||||||
|
}
|
||||||
|
if lastError.Valid {
|
||||||
|
value.LastErrorCode = lastError.String
|
||||||
|
}
|
||||||
|
value.Converged = value.ObservedGeneration == value.Generation &&
|
||||||
|
value.FailureCount == 0 && value.ActualState == device.ActualOnline
|
||||||
|
if !value.Converged {
|
||||||
|
snapshot.Unconverged++
|
||||||
|
}
|
||||||
|
snapshot.Devices = append(snapshot.Devices, value)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return ConvergenceSnapshot{}, fmt.Errorf("iterate convergence snapshot: %w", err)
|
||||||
|
}
|
||||||
|
snapshot.Total = len(snapshot.Devices)
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceColumns = `d.id, d.tenant_id, d.site_id, d.serial_number, d.name, d.modality,
|
||||||
|
d.desired_state, d.actual_state, d.endpoint_ref, d.credential_ref,
|
||||||
|
d.path_name, d.generation, d.created_at, d.updated_at`
|
||||||
|
|
||||||
|
const deviceSelect = `SELECT ` + deviceColumns + ` FROM sense_devices d`
|
||||||
|
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDevice(row scanner) (device.Device, error) {
|
||||||
|
var value device.Device
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
err := row.Scan(
|
||||||
|
&value.ID, &value.TenantID, &value.SiteID, &value.SerialNumber,
|
||||||
|
&value.Name, &value.Modality, &value.DesiredState, &value.ActualState,
|
||||||
|
&value.EndpointRef, &value.CredentialRef, &value.PathName,
|
||||||
|
&value.Generation, &createdAt, &updatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return device.Device{}, err
|
||||||
|
}
|
||||||
|
value.CreatedAt, err = parseTime(createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return device.Device{}, err
|
||||||
|
}
|
||||||
|
value.UpdatedAt, err = parseTime(updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return device.Device{}, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLite) capabilities(ctx context.Context, id string) ([]device.Capability, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT capability FROM sense_device_capabilities WHERE device_id = ? ORDER BY capability`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list device capabilities: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
values := make([]device.Capability, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var value device.Capability
|
||||||
|
if err := rows.Scan(&value); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan device capability: %w", err)
|
||||||
|
}
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate device capabilities: %w", err)
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedCapabilities(values []device.Capability) []device.Capability {
|
||||||
|
result := append([]device.Capability(nil), values...)
|
||||||
|
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(value time.Time) string {
|
||||||
|
return value.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTime(value string) (time.Time, error) {
|
||||||
|
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("parse stored timestamp: %w", err)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"yovision/sense/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultVideoQuotaRejectsSeventeenthChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
store := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant-a", ID: "site-a", Name: "Site A"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for index := 1; index <= device.DefaultVideoChannels; index++ {
|
||||||
|
if err := store.CreateDevice(ctx, videoDevice(index, "tenant-a", "site-a")); err != nil {
|
||||||
|
t.Fatalf("create channel %d: %v", index, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := store.CreateDevice(ctx, videoDevice(17, "tenant-a", "site-a"))
|
||||||
|
var quotaError *device.QuotaExceededError
|
||||||
|
if !errors.As(err, "aError) || quotaError.Limit != 16 {
|
||||||
|
t.Fatalf("expected 16-channel quota error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfiguredMaximumAccepts128AndRejects129(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
store := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.EnsureSite(ctx, device.Site{
|
||||||
|
TenantID: "tenant-b", ID: "site-b", Name: "Site B", MaxVideoChannels: 128,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for index := 1; index <= 128; index++ {
|
||||||
|
if err := store.CreateDevice(ctx, videoDevice(index, "tenant-b", "site-b")); err != nil {
|
||||||
|
t.Fatalf("create channel %d: %v", index, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := store.CreateDevice(ctx, videoDevice(129, "tenant-b", "site-b"))
|
||||||
|
var quotaError *device.QuotaExceededError
|
||||||
|
if !errors.As(err, "aError) || quotaError.Limit != 128 {
|
||||||
|
t.Fatalf("expected 128-channel quota error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSiteRejectsCapacityAbove128(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
store := openTestStore(t)
|
||||||
|
err := store.EnsureSite(context.Background(), device.Site{
|
||||||
|
TenantID: "tenant", ID: "site", Name: "Site", MaxVideoChannels: 129,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected capacity 129 to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonVideoDeviceDoesNotConsumeVideoQuota(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
store := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.EnsureSite(ctx, device.Site{
|
||||||
|
TenantID: "tenant", ID: "site", Name: "Site", MaxVideoChannels: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
radar := device.Device{
|
||||||
|
ID: "radar-1", TenantID: "tenant", SiteID: "site", SerialNumber: "radar-1",
|
||||||
|
Name: "Radar", Modality: device.ModalityRadar,
|
||||||
|
Capabilities: []device.Capability{device.CapabilityTelemetry},
|
||||||
|
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||||
|
}
|
||||||
|
if err := store.CreateDevice(ctx, radar); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.CreateDevice(ctx, videoDevice(1, "tenant", "site")); err != nil {
|
||||||
|
t.Fatalf("video channel should remain available: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnablingSeventeenthVideoDeviceIsRejected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
store := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant-c", ID: "site-c", Name: "Site C"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for index := 1; index <= 17; index++ {
|
||||||
|
value := videoDevice(index, "tenant-c", "site-c")
|
||||||
|
if index == 17 {
|
||||||
|
value.DesiredState = device.DesiredDisabled
|
||||||
|
}
|
||||||
|
if err := store.CreateDevice(ctx, value); err != nil {
|
||||||
|
t.Fatalf("create device %d: %v", index, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := store.SetDesiredState(ctx, "camera-017", device.DesiredEnabled)
|
||||||
|
var quotaError *device.QuotaExceededError
|
||||||
|
if !errors.As(err, "aError) || quotaError.Limit != 16 {
|
||||||
|
t.Fatalf("expected enable to enforce quota, got %v", err)
|
||||||
|
}
|
||||||
|
value, err := store.GetDevice(ctx, "camera-017")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value.DesiredState != device.DesiredDisabled {
|
||||||
|
t.Fatal("failed enable must leave the existing desired state unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLowerQuotaDoesNotDisableExistingStreams(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
store := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.EnsureSite(ctx, device.Site{
|
||||||
|
TenantID: "tenant-d", ID: "site-d", Name: "Site D", MaxVideoChannels: 2,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for index := 1; index <= 2; index++ {
|
||||||
|
if err := store.CreateDevice(ctx, videoDevice(index, "tenant-d", "site-d")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := store.EnsureSite(ctx, device.Site{
|
||||||
|
TenantID: "tenant-d", ID: "site-d", Name: "Site D", MaxVideoChannels: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for index := 1; index <= 2; index++ {
|
||||||
|
value, err := store.GetDevice(ctx, fmt.Sprintf("camera-%03d", index))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value.DesiredState != device.DesiredEnabled {
|
||||||
|
t.Fatalf("existing channel %d was disabled", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvergenceSnapshotAndRuntimeReconcileRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
store := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant", ID: "site", Name: "Site"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.CreateDevice(ctx, videoDevice(1, "tenant", "site")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||||
|
if err := store.MarkReconciled(ctx, "camera-001", 1, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.UpdateActualState(ctx, "camera-001", device.ActualOnline, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot, err := store.ConvergenceSnapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if snapshot.Total != 1 || snapshot.Unconverged != 0 {
|
||||||
|
t.Fatalf("expected converged snapshot, got %+v", snapshot)
|
||||||
|
}
|
||||||
|
if err := store.RequestReconcile(ctx, "camera-001", now.Add(time.Second)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot, err = store.ConvergenceSnapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if snapshot.Unconverged != 1 || snapshot.Devices[0].ObservedGeneration != 0 {
|
||||||
|
t.Fatalf("runtime loss must invalidate convergence: %+v", snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTestStore(t *testing.T) *SQLite {
|
||||||
|
t.Helper()
|
||||||
|
dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "sense.db"))
|
||||||
|
store, err := OpenSQLite(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = store.Close() })
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func videoDevice(index int, tenantID, siteID string) device.Device {
|
||||||
|
id := fmt.Sprintf("camera-%03d", index)
|
||||||
|
return device.Device{
|
||||||
|
ID: id, TenantID: tenantID, SiteID: siteID, AreaID: "area-default",
|
||||||
|
SerialNumber: id, Name: id,
|
||||||
|
Modality: device.ModalityVideo,
|
||||||
|
Capabilities: []device.Capability{device.CapabilityVideoCapture, device.CapabilitySpatialRule},
|
||||||
|
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||||
|
EndpointRef: "onvif://" + id, CredentialRef: "secret://" + id,
|
||||||
|
PathName: "sense/" + tenantID + "/" + siteID + "/" + id,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$CameraEnv,
|
||||||
|
[string]$RuntimeRoot = (Join-Path ([IO.Path]::GetTempPath()) 'yovision-t006'),
|
||||||
|
[ValidateRange(1, 1440)]
|
||||||
|
[int]$ObservationMinutes = 30,
|
||||||
|
[switch]$KeepSession
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
|
||||||
|
function Read-EnvFile([string]$Path) {
|
||||||
|
$result = @{}
|
||||||
|
Get-Content -LiteralPath $Path | ForEach-Object {
|
||||||
|
$line = $_.Trim()
|
||||||
|
if (-not $line -or $line.StartsWith('#') -or -not $line.Contains('=')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$parts = $line -split '=', 2
|
||||||
|
$value = $parts[1].Trim()
|
||||||
|
if ($value.Length -ge 2 -and (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'")))) {
|
||||||
|
$value = $value.Substring(1, $value.Length - 2)
|
||||||
|
}
|
||||||
|
$result[$parts[0].Trim().ToLowerInvariant()] = $value
|
||||||
|
}
|
||||||
|
return $result
|
||||||
|
}
|
||||||
|
|
||||||
|
function Require-Keys([hashtable]$Config, [string[]]$Keys) {
|
||||||
|
foreach ($key in $Keys) {
|
||||||
|
if (-not $Config.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($Config[$key])) {
|
||||||
|
throw "camera environment is missing required key: $key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-PortFree([int]$Port) {
|
||||||
|
$client = [Net.Sockets.TcpClient]::new()
|
||||||
|
try {
|
||||||
|
$task = $client.ConnectAsync('127.0.0.1', $Port)
|
||||||
|
if ($task.Wait(250) -and $client.Connected) {
|
||||||
|
throw "required local port is already in use: $Port"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch [AggregateException] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
catch [Net.Sockets.SocketException] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$client.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-Port([int]$Port, [int]$TimeoutSeconds = 30) {
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||||
|
$client = [Net.Sockets.TcpClient]::new()
|
||||||
|
try {
|
||||||
|
$task = $client.ConnectAsync('127.0.0.1', $Port)
|
||||||
|
if ($task.Wait(500) -and $client.Connected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$client.Dispose()
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
throw "local port did not become ready: $Port"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-ManagedProcess(
|
||||||
|
[string]$Name,
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[hashtable]$Environment = @{}
|
||||||
|
) {
|
||||||
|
$start = [Diagnostics.ProcessStartInfo]::new()
|
||||||
|
$start.FileName = $FilePath
|
||||||
|
$start.WorkingDirectory = $session
|
||||||
|
$start.UseShellExecute = $false
|
||||||
|
$start.CreateNoWindow = $true
|
||||||
|
$start.RedirectStandardOutput = $true
|
||||||
|
$start.RedirectStandardError = $true
|
||||||
|
foreach ($argument in $Arguments) {
|
||||||
|
$start.ArgumentList.Add($argument)
|
||||||
|
}
|
||||||
|
foreach ($entry in $Environment.GetEnumerator()) {
|
||||||
|
$start.Environment[$entry.Key] = [string]$entry.Value
|
||||||
|
}
|
||||||
|
$process = [Diagnostics.Process]::new()
|
||||||
|
$process.StartInfo = $start
|
||||||
|
if (-not $process.Start()) {
|
||||||
|
throw "failed to start process: $Name"
|
||||||
|
}
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Name = $Name
|
||||||
|
Process = $process
|
||||||
|
Stdout = $process.StandardOutput.ReadToEndAsync()
|
||||||
|
Stderr = $process.StandardError.ReadToEndAsync()
|
||||||
|
StartedAt = [DateTimeOffset]::UtcNow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-ManagedProcess($Managed) {
|
||||||
|
if ($null -eq $Managed -or $null -eq $Managed.Process) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (-not $Managed.Process.HasExited) {
|
||||||
|
# Every fixture is started as a leaf process. Killing only that exact
|
||||||
|
# process avoids Windows process-tree edge cases during fault tests.
|
||||||
|
$Managed.Process.Kill()
|
||||||
|
$Managed.Process.WaitForExit(10000) | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Alive($Managed) {
|
||||||
|
if ($null -eq $Managed -or $Managed.Process.HasExited) {
|
||||||
|
$code = if ($null -eq $Managed) { 'not-started' } else { $Managed.Process.ExitCode }
|
||||||
|
throw "required process exited: $($Managed.Name), code=$code"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-LabStatus {
|
||||||
|
$lastDiagnostic = ''
|
||||||
|
foreach ($attempt in 1..5) {
|
||||||
|
$raw = @(& $labBinary status -db $databaseDSN 2>&1)
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
try {
|
||||||
|
return (($raw -join "`n") | ConvertFrom-Json)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$lastDiagnostic = 'invalid JSON response'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$lastDiagnostic = (($raw -join "`n") -split "`r?`n" | Select-Object -Last 2) -join ' | '
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
throw "sense-lab status failed after retries: $lastDiagnostic"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-Converged([int]$TimeoutSeconds = 180) {
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||||
|
try {
|
||||||
|
$snapshot = Invoke-LabStatus
|
||||||
|
if ([int]$snapshot.total -eq 5 -and [int]$snapshot.unconverged -eq 0) {
|
||||||
|
return [Math]::Round($watch.Elapsed.TotalSeconds, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
throw 'five devices did not converge before timeout'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-DeviceState([string]$ID, [string]$State, [int]$TimeoutSeconds = 90) {
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||||
|
try {
|
||||||
|
$snapshot = Invoke-LabStatus
|
||||||
|
$match = @($snapshot.devices | Where-Object { $_.id -eq $ID })
|
||||||
|
if ($match.Count -eq 1 -and $match[0].actual_state -eq $State) {
|
||||||
|
return [Math]::Round($watch.Elapsed.TotalSeconds, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
throw "device did not reach expected state: $ID/$State"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-SenseHealth($Managed, [int]$TimeoutSeconds = 30) {
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||||
|
if ($Managed.Process.HasExited) {
|
||||||
|
$output = @(
|
||||||
|
$Managed.Stdout.GetAwaiter().GetResult()
|
||||||
|
$Managed.Stderr.GetAwaiter().GetResult()
|
||||||
|
) -join "`n"
|
||||||
|
$summary = (($output -split "`r?`n") | Where-Object { $_ } | Select-Object -Last 3) -join ' | '
|
||||||
|
throw "Sense exited before health check, code=$($Managed.Process.ExitCode), output=$summary"
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$response = Invoke-RestMethod -Method Get -Uri 'http://127.0.0.1:18080/healthz' -TimeoutSec 2 -NoProxy
|
||||||
|
if ($response.status -eq 'ok') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
$listeners = @(Get-NetTCPConnection -State Listen -OwningProcess $Managed.Process.Id -ErrorAction SilentlyContinue |
|
||||||
|
ForEach-Object { "$($_.LocalAddress):$($_.LocalPort)" })
|
||||||
|
throw "Sense health endpoint did not become ready; process listeners=$($listeners -join ',')"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-Publisher([int]$Index) {
|
||||||
|
return Start-ManagedProcess "publisher-$Index" $ffmpeg @(
|
||||||
|
'-hide_banner', '-loglevel', 'warning', '-re',
|
||||||
|
'-f', 'lavfi', '-i', "testsrc2=size=640x360:rate=10",
|
||||||
|
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
|
||||||
|
'-pix_fmt', 'yuv420p', '-g', '10', '-an',
|
||||||
|
'-f', 'rtsp', '-rtsp_transport', 'tcp',
|
||||||
|
"rtsp://127.0.0.1:8555/synthetic-$Index"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-Proxy {
|
||||||
|
return Start-ManagedProcess 'real-camera-network-proxy' $proxyBinary @(
|
||||||
|
'-listen', '127.0.0.1:10554', '-upstream', "$($camera.host):$($camera.rtspport)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-Sense {
|
||||||
|
$environment = @{
|
||||||
|
SENSE_HTTP_ADDR = '127.0.0.1:18080'
|
||||||
|
SENSE_DB_DSN = $databaseDSN
|
||||||
|
SENSE_MEDIAMTX_URL = 'http://127.0.0.1:9997'
|
||||||
|
SENSE_RECONCILE_INTERVAL = '1s'
|
||||||
|
SENSE_PROBE_INTERVAL = '1s'
|
||||||
|
SENSE_ONVIF_MODE = 'standard'
|
||||||
|
SENSE_ONVIF_RTSP_REWRITE_HOST = '127.0.0.1'
|
||||||
|
SENSE_ONVIF_RTSP_REWRITE_PORT = '10554'
|
||||||
|
SENSE_ONVIF_RTSP_STRIP_QUERY = 'true'
|
||||||
|
SENSE_CREDENTIAL_CAMERA_ONVIF_USERNAME = $camera.onvifuser
|
||||||
|
SENSE_CREDENTIAL_CAMERA_ONVIF_PASSWORD = $camera.onvifpwd
|
||||||
|
SENSE_CREDENTIAL_CAMERA_RTSP_USERNAME = $camera.username
|
||||||
|
SENSE_CREDENTIAL_CAMERA_RTSP_PASSWORD = $camera.password
|
||||||
|
}
|
||||||
|
return Start-ManagedProcess -Name 'sense-api' -FilePath $senseBinary -Arguments @() -Environment $environment
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-ProductionMediaMTX {
|
||||||
|
return Start-ManagedProcess 'mediamtx-production' $mediaMTX @($productionConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||||
|
$cameraPath = (Resolve-Path -LiteralPath $CameraEnv).Path
|
||||||
|
$camera = Read-EnvFile $cameraPath
|
||||||
|
Require-Keys $camera @('host', 'username', 'password', 'rtspport', 'onvif', 'onvifuser', 'onvifpwd')
|
||||||
|
|
||||||
|
foreach ($port in 8554, 8555, 9997, 10554, 18080) {
|
||||||
|
Assert-PortFree $port
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $RuntimeRoot -Force | Out-Null
|
||||||
|
$mediaDirectory = Join-Path $RuntimeRoot 'mediamtx-v1.19.3'
|
||||||
|
$mediaMTX = Join-Path $mediaDirectory 'mediamtx.exe'
|
||||||
|
if (-not (Test-Path -LiteralPath $mediaMTX)) {
|
||||||
|
$zip = Join-Path $RuntimeRoot 'mediamtx_v1.19.3_windows_amd64.zip'
|
||||||
|
Invoke-WebRequest 'https://github.com/bluenviron/mediamtx/releases/download/v1.19.3/mediamtx_v1.19.3_windows_amd64.zip' -OutFile $zip
|
||||||
|
$actualHash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
if ($actualHash -ne '5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe') {
|
||||||
|
throw 'MediaMTX checksum mismatch'
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $mediaDirectory -Force | Out-Null
|
||||||
|
Expand-Archive -LiteralPath $zip -DestinationPath $mediaDirectory -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
$ffmpeg = (Get-Command ffmpeg -ErrorAction Stop).Source
|
||||||
|
$session = Join-Path $RuntimeRoot ('session-' + [Guid]::NewGuid().ToString('N'))
|
||||||
|
New-Item -ItemType Directory -Path $session | Out-Null
|
||||||
|
$senseBinary = Join-Path $session 'sense-api.exe'
|
||||||
|
$labBinary = Join-Path $session 'sense-lab.exe'
|
||||||
|
$proxyBinary = Join-Path $session 'rtsp-fault-proxy.exe'
|
||||||
|
$productionConfig = Join-Path $session 'mediamtx-production.yml'
|
||||||
|
$syntheticConfig = Join-Path $session 'mediamtx-synthetic.yml'
|
||||||
|
[IO.File]::Copy((Join-Path $repoRoot 'Sense\deploy\mediamtx.yml'), $productionConfig)
|
||||||
|
[IO.File]::Copy((Join-Path $repoRoot 'Sense\deploy\mediamtx-synthetic.yml'), $syntheticConfig)
|
||||||
|
|
||||||
|
& go -C (Join-Path $repoRoot 'Sense') build -o $senseBinary ./cmd/sense-api
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'build sense-api failed' }
|
||||||
|
& go -C (Join-Path $repoRoot 'Sense') build -o $labBinary ./cmd/sense-lab
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'build sense-lab failed' }
|
||||||
|
& go -C (Join-Path $repoRoot 'Sense') build -o $proxyBinary ./cmd/rtsp-fault-proxy
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'build RTSP fault proxy failed' }
|
||||||
|
|
||||||
|
$databasePath = Join-Path $session 'sense.db'
|
||||||
|
$databaseDSN = 'file:' + $databasePath.Replace('\', '/')
|
||||||
|
$manifestPath = Join-Path $session 'manifest.json'
|
||||||
|
$onvifEndpoint = if ($camera.onvif -match '^https?://') {
|
||||||
|
$camera.onvif
|
||||||
|
} else {
|
||||||
|
"http://$($camera.host):$($camera.onvif)/onvif/device_service"
|
||||||
|
}
|
||||||
|
$devices = @(
|
||||||
|
[ordered]@{ id = 'camera-real'; tenant_id = 'lab'; site_id = 'site'; serial_number = 'e9ed6a555ae0'; name = 'Approved real camera'; capabilities = @('video_capture', 'audio_capture'); endpoint_ref = $onvifEndpoint; credential_ref = 'env://camera'; path_name = 'sense/lab/site/camera-real' }
|
||||||
|
)
|
||||||
|
foreach ($index in 1..4) {
|
||||||
|
$devices += [ordered]@{ id = "synthetic-$index"; tenant_id = 'lab'; site_id = 'site'; serial_number = "synthetic-$index"; name = "Synthetic source $index"; capabilities = @('video_capture'); endpoint_ref = "rtsp://127.0.0.1:8555/synthetic-$index"; credential_ref = ''; path_name = "sense/lab/site/synthetic-$index" }
|
||||||
|
}
|
||||||
|
[ordered]@{
|
||||||
|
site = [ordered]@{ tenant_id = 'lab'; id = 'site'; name = 'T-006 Lab'; max_video_channels = 16 }
|
||||||
|
devices = $devices
|
||||||
|
} | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
|
||||||
|
|
||||||
|
$managed = [Collections.Generic.List[object]]::new()
|
||||||
|
$publishers = @{}
|
||||||
|
$events = [Collections.Generic.List[object]]::new()
|
||||||
|
$observationSamples = 0
|
||||||
|
$maxUnconverged = 0
|
||||||
|
$success = $false
|
||||||
|
$stage = 'starting fixtures'
|
||||||
|
$failureMessage = $null
|
||||||
|
try {
|
||||||
|
$sourceMedia = Start-ManagedProcess 'mediamtx-synthetic' $mediaMTX @($syntheticConfig)
|
||||||
|
$managed.Add($sourceMedia)
|
||||||
|
Wait-Port 8555
|
||||||
|
$productionMedia = Start-ProductionMediaMTX
|
||||||
|
$managed.Add($productionMedia)
|
||||||
|
Wait-Port 9997
|
||||||
|
|
||||||
|
foreach ($index in 1..4) {
|
||||||
|
$publishers[$index] = Start-Publisher $index
|
||||||
|
$managed.Add($publishers[$index])
|
||||||
|
}
|
||||||
|
$stage = 'checking synthetic publishers'
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
foreach ($publisher in $publishers.Values) { Assert-Alive $publisher }
|
||||||
|
|
||||||
|
$proxy = Start-Proxy
|
||||||
|
$managed.Add($proxy)
|
||||||
|
Wait-Port 10554
|
||||||
|
|
||||||
|
& $labBinary seed -db $databaseDSN -manifest $manifestPath | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'seed device ledger failed' }
|
||||||
|
$stage = 'initial convergence'
|
||||||
|
$sense = Start-Sense
|
||||||
|
$managed.Add($sense)
|
||||||
|
Wait-SenseHealth $sense
|
||||||
|
$initialSeconds = Wait-Converged
|
||||||
|
$events.Add([ordered]@{ event = 'initial_convergence'; seconds = $initialSeconds; unconverged = 0 })
|
||||||
|
|
||||||
|
$stage = 'real camera network recovery'
|
||||||
|
Stop-ManagedProcess $proxy
|
||||||
|
$offlineSeconds = Wait-DeviceState 'camera-real' 'offline'
|
||||||
|
$proxy = Start-Proxy
|
||||||
|
$managed.Add($proxy)
|
||||||
|
Wait-Port 10554
|
||||||
|
$recoverySeconds = Wait-Converged
|
||||||
|
$events.Add([ordered]@{ event = 'real_camera_network'; offline_detect_seconds = $offlineSeconds; recovery_seconds = $recoverySeconds; unconverged = 0 })
|
||||||
|
|
||||||
|
$stage = 'synthetic publisher recovery'
|
||||||
|
Stop-ManagedProcess $publishers[2]
|
||||||
|
$offlineSeconds = Wait-DeviceState 'synthetic-2' 'offline'
|
||||||
|
$publishers[2] = Start-Publisher 2
|
||||||
|
$managed.Add($publishers[2])
|
||||||
|
$recoverySeconds = Wait-Converged
|
||||||
|
$events.Add([ordered]@{ event = 'synthetic_publisher'; offline_detect_seconds = $offlineSeconds; recovery_seconds = $recoverySeconds; unconverged = 0 })
|
||||||
|
|
||||||
|
$stage = 'Sense restart recovery'
|
||||||
|
Stop-ManagedProcess $sense
|
||||||
|
$restartWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$sense = Start-Sense
|
||||||
|
$managed.Add($sense)
|
||||||
|
Wait-SenseHealth $sense
|
||||||
|
$recoverySeconds = Wait-Converged
|
||||||
|
$events.Add([ordered]@{ event = 'sense_restart'; process_ready_seconds = [Math]::Round($restartWatch.Elapsed.TotalSeconds, 1); recovery_seconds = $recoverySeconds; unconverged = 0 })
|
||||||
|
|
||||||
|
$stage = 'MediaMTX restart recovery'
|
||||||
|
Stop-ManagedProcess $productionMedia
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
$productionMedia = Start-ProductionMediaMTX
|
||||||
|
$managed.Add($productionMedia)
|
||||||
|
Wait-Port 9997
|
||||||
|
$recoverySeconds = Wait-Converged 240
|
||||||
|
$events.Add([ordered]@{ event = 'mediamtx_restart'; recovery_seconds = $recoverySeconds; unconverged = 0 })
|
||||||
|
|
||||||
|
$stage = 'checking MediaMTX path count'
|
||||||
|
$configured = Invoke-RestMethod -Method Get -Uri 'http://127.0.0.1:9997/v3/config/paths/list' -TimeoutSec 5 -NoProxy
|
||||||
|
if ([int]$configured.itemCount -ne 5) {
|
||||||
|
throw "expected 5 MediaMTX paths, got $($configured.itemCount)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$stage = 'stability observation'
|
||||||
|
$observation = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$targetSeconds = $ObservationMinutes * 60
|
||||||
|
while ($observation.Elapsed.TotalSeconds -lt $targetSeconds) {
|
||||||
|
Assert-Alive $sourceMedia
|
||||||
|
Assert-Alive $productionMedia
|
||||||
|
Assert-Alive $sense
|
||||||
|
Assert-Alive $proxy
|
||||||
|
foreach ($publisher in $publishers.Values) { Assert-Alive $publisher }
|
||||||
|
$snapshot = Invoke-LabStatus
|
||||||
|
$observationSamples++
|
||||||
|
$maxUnconverged = [Math]::Max($maxUnconverged, [int]$snapshot.unconverged)
|
||||||
|
if ([int]$snapshot.total -ne 5 -or [int]$snapshot.unconverged -ne 0) {
|
||||||
|
throw "observation detected unconverged devices: $($snapshot.unconverged)"
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 10
|
||||||
|
}
|
||||||
|
$observationSeconds = [Math]::Round($observation.Elapsed.TotalSeconds, 1)
|
||||||
|
$final = Invoke-LabStatus
|
||||||
|
$success = $true
|
||||||
|
[ordered]@{
|
||||||
|
success = $true
|
||||||
|
mediamtx_version = 'v1.19.3'
|
||||||
|
mediamtx_sha256 = '5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe'
|
||||||
|
source_count = 5
|
||||||
|
synthetic_publishers = 4
|
||||||
|
configured_paths = [int]$configured.itemCount
|
||||||
|
recovery = $events
|
||||||
|
observation_seconds = $observationSeconds
|
||||||
|
observation_samples = $observationSamples
|
||||||
|
maximum_unconverged = $maxUnconverged
|
||||||
|
final_unconverged = [int]$final.unconverged
|
||||||
|
} | ConvertTo-Json -Depth 8
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$failureMessage = "T-006 stage '$stage' failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
foreach ($item in @($managed)) {
|
||||||
|
Stop-ManagedProcess $item
|
||||||
|
}
|
||||||
|
if (-not $KeepSession) {
|
||||||
|
Get-ChildItem -LiteralPath $session -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object { $_.Attributes = 'Normal' }
|
||||||
|
if (Test-Path -LiteralPath $session) {
|
||||||
|
(Get-Item -LiteralPath $session -Force).Attributes = 'Directory'
|
||||||
|
Remove-Item -LiteralPath $session -Recurse -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $success) {
|
||||||
|
Write-Warning 'T-006 integration did not complete; no success evidence was emitted.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($failureMessage) {
|
||||||
|
throw $failureMessage
|
||||||
|
}
|
||||||
@@ -0,0 +1,973 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$PgRoot = 'D:\pgsql17',
|
||||||
|
[string]$RuntimeRoot = (Join-Path ([IO.Path]::GetTempPath()) 'yovision-t014'),
|
||||||
|
[ValidateRange(1, 1440)]
|
||||||
|
[int]$ObservationMinutes = 30,
|
||||||
|
[string]$OutputPath = '',
|
||||||
|
[switch]$PreflightOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
|
$sourceCount = 16
|
||||||
|
$faultIndexes = @(5, 6, 7, 8)
|
||||||
|
$sampleSeconds = 10
|
||||||
|
$mediaMTXVersion = 'v1.19.3'
|
||||||
|
$mediaMTXSHA256 = '5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe'
|
||||||
|
$mediaMTXExecutableSHA256 = '1cda85249312cb9463f9f94c5a712b9f160c9af3fd9490f0d4723911d7880e05'
|
||||||
|
$migrationNames = @(
|
||||||
|
'002_bell.sql',
|
||||||
|
'003_sense.sql',
|
||||||
|
'004_privileges.sql',
|
||||||
|
'005_area_policy.sql',
|
||||||
|
'006_device_operation_outbox.sql',
|
||||||
|
'007_privileges_area_audit.sql',
|
||||||
|
'008_control_api.sql',
|
||||||
|
'009_privileges_control_api.sql',
|
||||||
|
'010_reconcile_safety.sql',
|
||||||
|
'011_privileges_reconcile_safety.sql'
|
||||||
|
)
|
||||||
|
|
||||||
|
function ConvertTo-Base64Url([byte[]]$Bytes) {
|
||||||
|
return [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-RandomBytes([int]$Count) {
|
||||||
|
$bytes = [byte[]]::new($Count)
|
||||||
|
[Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
|
||||||
|
return $bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-SecretToken {
|
||||||
|
return ConvertTo-Base64Url (New-RandomBytes 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SHA256Hex([string]$Value) {
|
||||||
|
$bytes = [Text.Encoding]::UTF8.GetBytes($Value)
|
||||||
|
$digest = [Security.Cryptography.SHA256]::HashData($bytes)
|
||||||
|
return ([Convert]::ToHexString($digest)).ToLowerInvariant()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-FreeTcpPort([Collections.Generic.HashSet[int]]$Reserved) {
|
||||||
|
while ($true) {
|
||||||
|
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||||
|
$listener.Start()
|
||||||
|
$port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port
|
||||||
|
$listener.Stop()
|
||||||
|
if ($Reserved.Add($port)) {
|
||||||
|
return $port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-Port([int]$Port, [int]$TimeoutSeconds = 30) {
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||||
|
$client = [Net.Sockets.TcpClient]::new()
|
||||||
|
try {
|
||||||
|
$task = $client.ConnectAsync('127.0.0.1', $Port)
|
||||||
|
if ($task.Wait(500) -and $client.Connected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$client.Dispose()
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
throw "local process did not listen before timeout"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-ManagedProcess(
|
||||||
|
[string]$Name,
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[hashtable]$Environment = @{}
|
||||||
|
) {
|
||||||
|
$start = [Diagnostics.ProcessStartInfo]::new()
|
||||||
|
$start.FileName = $FilePath
|
||||||
|
$start.WorkingDirectory = $script:session
|
||||||
|
$start.UseShellExecute = $false
|
||||||
|
$start.CreateNoWindow = $true
|
||||||
|
$start.RedirectStandardOutput = $true
|
||||||
|
$start.RedirectStandardError = $true
|
||||||
|
foreach ($argument in $Arguments) {
|
||||||
|
$start.ArgumentList.Add($argument)
|
||||||
|
}
|
||||||
|
foreach ($entry in $Environment.GetEnumerator()) {
|
||||||
|
$start.Environment[$entry.Key] = [string]$entry.Value
|
||||||
|
}
|
||||||
|
$process = [Diagnostics.Process]::new()
|
||||||
|
$process.StartInfo = $start
|
||||||
|
if (-not $process.Start()) {
|
||||||
|
throw "failed to start required process: $Name"
|
||||||
|
}
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Name = $Name
|
||||||
|
Process = $process
|
||||||
|
Stdout = $process.StandardOutput.ReadToEndAsync()
|
||||||
|
Stderr = $process.StandardError.ReadToEndAsync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-ManagedProcess($Managed) {
|
||||||
|
if ($null -eq $Managed -or $null -eq $Managed.Process) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (-not $Managed.Process.HasExited) {
|
||||||
|
$Managed.Process.Kill()
|
||||||
|
$Managed.Process.WaitForExit(10000) | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-AllManagedProcesses($Items) {
|
||||||
|
$processes = @($Items | Where-Object { $null -ne $_ -and $null -ne $_.Process })
|
||||||
|
foreach ($item in $processes) {
|
||||||
|
try {
|
||||||
|
if (-not $item.Process.HasExited) {
|
||||||
|
$item.Process.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if (-not $item.Process.HasExited) { throw }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$cleanupWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while ($cleanupWatch.Elapsed.TotalSeconds -lt 10 -and
|
||||||
|
@($processes | Where-Object { -not $_.Process.HasExited }).Count -gt 0) {
|
||||||
|
Start-Sleep -Milliseconds 100
|
||||||
|
}
|
||||||
|
$remaining = @($processes | Where-Object { -not $_.Process.HasExited })
|
||||||
|
if ($remaining.Count -gt 0) {
|
||||||
|
Write-Warning "managed processes did not exit within the shared cleanup deadline: $($remaining.Count)"
|
||||||
|
}
|
||||||
|
foreach ($item in $processes) {
|
||||||
|
if ($item.Process.HasExited) {
|
||||||
|
$item.Stdout.Wait(2000) | Out-Null
|
||||||
|
$item.Stderr.Wait(2000) | Out-Null
|
||||||
|
$item.Process.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Remove-SessionDirectory([string]$Path) {
|
||||||
|
Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue |
|
||||||
|
ForEach-Object { $_.Attributes = 'Normal' }
|
||||||
|
(Get-Item -LiteralPath $Path -Force).Attributes = 'Directory'
|
||||||
|
$deleteWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while (Test-Path -LiteralPath $Path) {
|
||||||
|
try {
|
||||||
|
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if ($deleteWatch.Elapsed.TotalSeconds -ge 10) { throw }
|
||||||
|
Start-Sleep -Milliseconds 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Alive($Managed) {
|
||||||
|
if ($null -eq $Managed -or $Managed.Process.HasExited) {
|
||||||
|
$code = if ($null -eq $Managed) { 'not-started' } else { $Managed.Process.ExitCode }
|
||||||
|
throw "required process exited: $($Managed.Name), code=$code"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Checked {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] [string]$FilePath,
|
||||||
|
[Parameter(ValueFromRemainingArguments)] [string[]]$Arguments
|
||||||
|
)
|
||||||
|
& $FilePath @Arguments 2>&1 | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "required command failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-ControlRequest(
|
||||||
|
[string]$Method,
|
||||||
|
[string]$Path,
|
||||||
|
[int[]]$ExpectedStatus,
|
||||||
|
$Body = $null,
|
||||||
|
[hashtable]$ExtraHeaders = @{}
|
||||||
|
) {
|
||||||
|
$headers = @{
|
||||||
|
Authorization = "Bearer $script:controlToken"
|
||||||
|
Accept = 'application/json'
|
||||||
|
}
|
||||||
|
foreach ($entry in $ExtraHeaders.GetEnumerator()) {
|
||||||
|
$headers[$entry.Key] = $entry.Value
|
||||||
|
}
|
||||||
|
$parameters = @{
|
||||||
|
Method = $Method
|
||||||
|
Uri = "http://127.0.0.1:$script:senseHTTPPort$Path"
|
||||||
|
Headers = $headers
|
||||||
|
TimeoutSec = 15
|
||||||
|
NoProxy = $true
|
||||||
|
SkipHttpErrorCheck = $true
|
||||||
|
}
|
||||||
|
if ($null -ne $Body) {
|
||||||
|
$parameters.ContentType = 'application/json'
|
||||||
|
$parameters.Body = ($Body | ConvertTo-Json -Depth 10 -Compress)
|
||||||
|
}
|
||||||
|
$response = Invoke-WebRequest @parameters
|
||||||
|
$statusCode = [int]$response.StatusCode
|
||||||
|
$statusMatches = $false
|
||||||
|
foreach ($expected in $ExpectedStatus) {
|
||||||
|
if ([int]$expected -eq $statusCode) {
|
||||||
|
$statusMatches = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $statusMatches) {
|
||||||
|
$code = 'unknown'
|
||||||
|
try {
|
||||||
|
$code = ((Get-ResponseText $response) | ConvertFrom-Json).code
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
throw "Control API returned unexpected status $statusCode, expected=$($ExpectedStatus -join ','), code=$code"
|
||||||
|
}
|
||||||
|
return $response
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ResponseText($Response) {
|
||||||
|
if ($Response.Content -is [byte[]]) {
|
||||||
|
return [Text.Encoding]::UTF8.GetString($Response.Content)
|
||||||
|
}
|
||||||
|
return [string]$Response.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
function Convert-ResponseJSON($Response) {
|
||||||
|
return ((Get-ResponseText $Response) | ConvertFrom-Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ResponseETag($Response) {
|
||||||
|
$value = [string]$Response.Headers.ETag
|
||||||
|
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||||
|
throw 'Control API response did not contain ETag'
|
||||||
|
}
|
||||||
|
return $value
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DevicePage {
|
||||||
|
$response = Invoke-ControlRequest 'GET' '/api/v1/sites/site-t014/devices?limit=100' @(200)
|
||||||
|
return Convert-ResponseJSON $response
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DeviceEntries([string[]]$DeviceIDs) {
|
||||||
|
$entries = [Collections.Generic.List[object]]::new()
|
||||||
|
foreach ($id in $DeviceIDs) {
|
||||||
|
$response = Invoke-ControlRequest 'GET' "/api/v1/sites/site-t014/devices/$id" @(200)
|
||||||
|
$entries.Add([pscustomobject]@{
|
||||||
|
DeviceID = $id
|
||||||
|
ETag = Get-ResponseETag $response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return @($entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-BatchDesiredState(
|
||||||
|
[string]$DesiredState,
|
||||||
|
[object[]]$Entries,
|
||||||
|
[string]$KeySuffix
|
||||||
|
) {
|
||||||
|
$items = @($Entries | ForEach-Object {
|
||||||
|
[ordered]@{
|
||||||
|
device_id = $_.DeviceID
|
||||||
|
etag = $_.ETag
|
||||||
|
desired_state = $DesiredState
|
||||||
|
}
|
||||||
|
})
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$response = Invoke-ControlRequest 'POST' '/api/v1/sites/site-t014/devices:batchDesiredState' @(202) ([ordered]@{
|
||||||
|
items = $items
|
||||||
|
reason = "T-014 laboratory $DesiredState baseline"
|
||||||
|
}) @{ 'Idempotency-Key' = "t014-batch-$KeySuffix-$script:runNonce" }
|
||||||
|
$operation = Convert-ResponseJSON $response
|
||||||
|
$results = @($operation.results)
|
||||||
|
if ($operation.status -ne 'succeeded' -or $results.Count -ne $sourceCount -or
|
||||||
|
@($results | Where-Object { $_.status -ne 'succeeded' }).Count -ne 0) {
|
||||||
|
$statusCounts = @($results | Group-Object status | Sort-Object Name |
|
||||||
|
ForEach-Object { "$($_.Name):$($_.Count)" }) -join ','
|
||||||
|
$errorCounts = @($results | Where-Object { $_.error_code } | Group-Object error_code | Sort-Object Name |
|
||||||
|
ForEach-Object { "$($_.Name):$($_.Count)" }) -join ','
|
||||||
|
throw "batch desired-state operation was not fully successful: operation=$($operation.status), items=$($results.Count), statuses=$statusCounts, errors=$errorCounts"
|
||||||
|
}
|
||||||
|
return [ordered]@{
|
||||||
|
status = [string]$operation.status
|
||||||
|
item_count = $results.Count
|
||||||
|
request_seconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-MediaInventory([int]$APIPort) {
|
||||||
|
$config = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$APIPort/v3/config/paths/list?page=0&itemsPerPage=100" -TimeoutSec 5 -NoProxy
|
||||||
|
$runtime = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$APIPort/v3/paths/list?page=0&itemsPerPage=100" -TimeoutSec 5 -NoProxy
|
||||||
|
$items = @($runtime.items)
|
||||||
|
[UInt64]$inboundBytes = 0
|
||||||
|
[UInt64]$frameErrors = 0
|
||||||
|
foreach ($item in $items) {
|
||||||
|
if ($null -ne $item.inboundBytes) {
|
||||||
|
$inboundBytes += [UInt64]$item.inboundBytes
|
||||||
|
}
|
||||||
|
if ($null -ne $item.inboundFramesInError) {
|
||||||
|
$frameErrors += [UInt64]$item.inboundFramesInError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Configured = [int]$config.itemCount
|
||||||
|
Runtime = [int]$runtime.itemCount
|
||||||
|
Online = @($items | Where-Object { $_.online -eq $true -and $_.available -eq $true }).Count
|
||||||
|
InboundBytes = $inboundBytes
|
||||||
|
FrameErrors = $frameErrors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SenseMetric([string]$Name, [string]$Labels) {
|
||||||
|
$response = Invoke-WebRequest -Method Get -Uri "http://127.0.0.1:$script:senseHTTPPort/metrics" -TimeoutSec 5 -NoProxy
|
||||||
|
$prefix = [Regex]::Escape($Name + $Labels)
|
||||||
|
$match = [Regex]::Match((Get-ResponseText $response), "(?m)^$prefix\s+([-+0-9.eE]+)$")
|
||||||
|
if (-not $match.Success) {
|
||||||
|
throw "required Sense metric was not found: $Name"
|
||||||
|
}
|
||||||
|
return [double]::Parse($match.Groups[1].Value, [Globalization.CultureInfo]::InvariantCulture)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-SenseHealth($Managed, [int]$TimeoutSeconds = 45) {
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||||
|
Assert-Alive $Managed
|
||||||
|
try {
|
||||||
|
$ready = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$script:senseHTTPPort/readyz" -TimeoutSec 2 -NoProxy
|
||||||
|
if ($ready.status -eq 'ready') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
throw 'Sense readiness endpoint did not become ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-SourcePublishers([int]$TimeoutSeconds = 90) {
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||||
|
foreach ($publisher in $script:publishers.Values) {
|
||||||
|
Assert-Alive $publisher
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$inventory = Get-MediaInventory $script:sourceAPIPort
|
||||||
|
if ($inventory.Runtime -eq $sourceCount -and $inventory.Online -eq $sourceCount) {
|
||||||
|
return [Math]::Round($watch.Elapsed.TotalSeconds, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
throw 'independent source publishers did not all become online'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-Convergence([ValidateSet('enabled', 'disabled')] [string]$DesiredState, [int]$TimeoutSeconds = 300) {
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$last = 'no observation'
|
||||||
|
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||||
|
try {
|
||||||
|
$page = Get-DevicePage
|
||||||
|
$devices = @($page.items)
|
||||||
|
$inventory = Get-MediaInventory $script:productionAPIPort
|
||||||
|
$totalMetric = Get-SenseMetric 'sense_reconcile_devices' '{state="total"}'
|
||||||
|
$unconvergedMetric = Get-SenseMetric 'sense_reconcile_devices' '{state="unconverged"}'
|
||||||
|
if ($DesiredState -eq 'enabled') {
|
||||||
|
$stateOK = $devices.Count -eq $sourceCount -and
|
||||||
|
@($devices | Where-Object {
|
||||||
|
$_.desired_state -ne 'enabled' -or $_.actual_state -ne 'online' -or -not $_.converged
|
||||||
|
}).Count -eq 0
|
||||||
|
$mediaOK = $inventory.Configured -eq $sourceCount -and
|
||||||
|
$inventory.Runtime -eq $sourceCount -and $inventory.Online -eq $sourceCount
|
||||||
|
$metricsOK = $totalMetric -eq $sourceCount -and $unconvergedMetric -eq 0
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$stateOK = $devices.Count -eq $sourceCount -and
|
||||||
|
@($devices | Where-Object {
|
||||||
|
$_.desired_state -ne 'disabled' -or $_.actual_state -ne 'offline' -or -not $_.converged
|
||||||
|
}).Count -eq 0
|
||||||
|
$mediaOK = $inventory.Configured -eq 0 -and $inventory.Runtime -eq 0
|
||||||
|
$metricsOK = $totalMetric -eq 0 -and $unconvergedMetric -eq 0
|
||||||
|
}
|
||||||
|
$last = "devices=$($devices.Count), configured=$($inventory.Configured), runtime=$($inventory.Runtime), online=$($inventory.Online), unconverged=$unconvergedMetric"
|
||||||
|
if ($stateOK -and $mediaOK -and $metricsOK) {
|
||||||
|
return [Math]::Round($watch.Elapsed.TotalSeconds, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$last = $_.Exception.Message
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
throw "devices did not reach $DesiredState convergence: $last"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-PartialFault([int]$TimeoutSeconds = 120) {
|
||||||
|
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||||
|
try {
|
||||||
|
$page = Get-DevicePage
|
||||||
|
$devices = @($page.items)
|
||||||
|
$online = @($devices | Where-Object { $_.actual_state -eq 'online' }).Count
|
||||||
|
$notOnline = $devices.Count - $online
|
||||||
|
if ($devices.Count -eq $sourceCount -and $online -eq ($sourceCount - $faultIndexes.Count) -and
|
||||||
|
$notOnline -eq $faultIndexes.Count) {
|
||||||
|
return [ordered]@{
|
||||||
|
detection_seconds = [Math]::Round($watch.Elapsed.TotalSeconds, 1)
|
||||||
|
affected = $notOnline
|
||||||
|
unaffected_online = $online
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
throw 'four-source fault was not isolated to exactly four devices'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-Publisher([int]$Index) {
|
||||||
|
$name = 'synthetic-{0:D2}' -f $Index
|
||||||
|
return Start-ManagedProcess "publisher-$Index" $script:ffmpeg @(
|
||||||
|
'-hide_banner', '-loglevel', 'warning', '-re', '-stream_loop', '-1',
|
||||||
|
'-i', $script:fixturePath, '-map', '0:v:0', '-c', 'copy', '-an',
|
||||||
|
'-f', 'rtsp', '-rtsp_transport', 'tcp',
|
||||||
|
"rtsp://127.0.0.1:$script:sourceRTSPPort/$name"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ProcessResourceSample($Managed, [double]$PreviousCPU, [double]$ElapsedSeconds) {
|
||||||
|
Assert-Alive $Managed
|
||||||
|
$Managed.Process.Refresh()
|
||||||
|
$totalCPU = $Managed.Process.TotalProcessorTime.TotalSeconds
|
||||||
|
$normalizedCPU = 0.0
|
||||||
|
if ($ElapsedSeconds -gt 0) {
|
||||||
|
$normalizedCPU = (($totalCPU - $PreviousCPU) / $ElapsedSeconds / [Environment]::ProcessorCount) * 100
|
||||||
|
}
|
||||||
|
return [pscustomobject]@{
|
||||||
|
TotalCPU = $totalCPU
|
||||||
|
CPUPercent = [Math]::Max(0, $normalizedCPU)
|
||||||
|
WorkingSetMiB = $Managed.Process.WorkingSet64 / 1MB
|
||||||
|
PrivateMiB = $Managed.Process.PrivateMemorySize64 / 1MB
|
||||||
|
Handles = $Managed.Process.HandleCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Average([Collections.Generic.List[double]]$Values) {
|
||||||
|
if ($Values.Count -eq 0) { return 0.0 }
|
||||||
|
return ($Values | Measure-Object -Average).Average
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Maximum([Collections.Generic.List[double]]$Values) {
|
||||||
|
if ($Values.Count -eq 0) { return 0.0 }
|
||||||
|
return ($Values | Measure-Object -Maximum).Maximum
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PostgresScalar([string]$Query) {
|
||||||
|
$value = & $script:psql -X -A -t -v ON_ERROR_STOP=1 -d $script:adminDatabaseDSN -c $Query
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw 'PostgreSQL observation query failed'
|
||||||
|
}
|
||||||
|
return (($value | Out-String).Trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Result([string]$JSON) {
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||||
|
$resolvedOutput = [IO.Path]::GetFullPath($OutputPath)
|
||||||
|
$repoPrefix = $script:repoRoot.TrimEnd('\') + '\'
|
||||||
|
if ($resolvedOutput.StartsWith($repoPrefix, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw 'capacity result output must stay outside the repository'
|
||||||
|
}
|
||||||
|
$parent = Split-Path -Parent $resolvedOutput
|
||||||
|
if (-not (Test-Path -LiteralPath $parent -PathType Container)) {
|
||||||
|
throw 'capacity result output directory does not exist'
|
||||||
|
}
|
||||||
|
[IO.File]::WriteAllText($resolvedOutput, $JSON + [Environment]::NewLine, [Text.UTF8Encoding]::new($false))
|
||||||
|
}
|
||||||
|
Write-Output $JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
$script:repoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..')).Path
|
||||||
|
$pgRootResolved = (Resolve-Path -LiteralPath $PgRoot).Path
|
||||||
|
$pgBin = Join-Path $pgRootResolved 'bin'
|
||||||
|
$script:initdb = Join-Path $pgBin 'initdb.exe'
|
||||||
|
$script:pgCtl = Join-Path $pgBin 'pg_ctl.exe'
|
||||||
|
$script:psql = Join-Path $pgBin 'psql.exe'
|
||||||
|
$script:createdb = Join-Path $pgBin 'createdb.exe'
|
||||||
|
$postgres = Join-Path $pgBin 'postgres.exe'
|
||||||
|
foreach ($required in @($script:initdb, $script:pgCtl, $script:psql, $script:createdb, $postgres)) {
|
||||||
|
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
|
||||||
|
throw 'PostgreSQL executable is missing under the selected PgRoot'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$postgresVersion = (& $postgres --version 2>&1 | Out-String).Trim()
|
||||||
|
if ($LASTEXITCODE -ne 0 -or $postgresVersion -notmatch 'PostgreSQL\) 17\.10$') {
|
||||||
|
throw 'T-014 requires the frozen PostgreSQL 17.10 binaries'
|
||||||
|
}
|
||||||
|
$script:ffmpeg = (Get-Command ffmpeg -ErrorAction Stop).Source
|
||||||
|
$ffmpegVersion = (& $script:ffmpeg -version 2>&1 | Select-Object -First 1)
|
||||||
|
if ($ffmpegVersion -notmatch '^ffmpeg version 8\.1\.2') {
|
||||||
|
throw 'T-014 requires the frozen FFmpeg 8.1.2 build'
|
||||||
|
}
|
||||||
|
$senseDirectory = Join-Path $script:repoRoot 'Sense'
|
||||||
|
$goVersion = (& go '-C' $senseDirectory 'version' 2>&1 | Out-String).Trim()
|
||||||
|
if ($LASTEXITCODE -ne 0 -or $goVersion -notmatch '^go version go1\.26\.5 ') {
|
||||||
|
throw 'T-014 requires the frozen Sense Go 1.26.5 toolchain'
|
||||||
|
}
|
||||||
|
|
||||||
|
$systemTemp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')
|
||||||
|
$runtimeResolved = [IO.Path]::GetFullPath($RuntimeRoot).TrimEnd('\')
|
||||||
|
$runtimePrefix = $systemTemp + '\'
|
||||||
|
if (-not $runtimeResolved.StartsWith($runtimePrefix, [StringComparison]::OrdinalIgnoreCase) -or
|
||||||
|
[IO.Path]::GetFileName($runtimeResolved) -notlike 'yovision-t014*') {
|
||||||
|
throw 'RuntimeRoot must be a yovision-t014 directory under the system temporary directory'
|
||||||
|
}
|
||||||
|
|
||||||
|
$mediaDirectory = Join-Path $runtimeResolved 'mediamtx-v1.19.3'
|
||||||
|
$mediaMTX = Join-Path $mediaDirectory 'mediamtx.exe'
|
||||||
|
if ($PreflightOnly) {
|
||||||
|
$memoryGiB = 0.0
|
||||||
|
try { $memoryGiB = (Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB } catch {}
|
||||||
|
Write-Result (([ordered]@{
|
||||||
|
success = $true
|
||||||
|
preflight_only = $true
|
||||||
|
source_count = $sourceCount
|
||||||
|
observation_minutes = $ObservationMinutes
|
||||||
|
formal_eligible = $ObservationMinutes -ge 30
|
||||||
|
mediamtx_cached = Test-Path -LiteralPath $mediaMTX -PathType Leaf
|
||||||
|
postgres_version = $postgresVersion
|
||||||
|
ffmpeg_version = [string]$ffmpegVersion
|
||||||
|
go_version = $goVersion
|
||||||
|
logical_processors = [Environment]::ProcessorCount
|
||||||
|
total_memory_gib = [Math]::Round($memoryGiB, 2)
|
||||||
|
} | ConvertTo-Json -Depth 5))
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $runtimeResolved -Force | Out-Null
|
||||||
|
if (-not (Test-Path -LiteralPath $mediaMTX -PathType Leaf)) {
|
||||||
|
$zip = Join-Path $runtimeResolved 'mediamtx_v1.19.3_windows_amd64.zip'
|
||||||
|
Invoke-WebRequest 'https://github.com/bluenviron/mediamtx/releases/download/v1.19.3/mediamtx_v1.19.3_windows_amd64.zip' -OutFile $zip
|
||||||
|
$actualHash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
if ($actualHash -ne $mediaMTXSHA256) {
|
||||||
|
throw 'MediaMTX checksum mismatch'
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $mediaDirectory -Force | Out-Null
|
||||||
|
Expand-Archive -LiteralPath $zip -DestinationPath $mediaDirectory -Force
|
||||||
|
}
|
||||||
|
if ((Get-FileHash -LiteralPath $mediaMTX -Algorithm SHA256).Hash.ToLowerInvariant() -ne $mediaMTXExecutableSHA256) {
|
||||||
|
throw 'MediaMTX executable checksum mismatch'
|
||||||
|
}
|
||||||
|
|
||||||
|
$script:runNonce = [Guid]::NewGuid().ToString('N')
|
||||||
|
$script:session = Join-Path $runtimeResolved ("session-$script:runNonce")
|
||||||
|
New-Item -ItemType Directory -Path $script:session | Out-Null
|
||||||
|
$reservedPorts = [Collections.Generic.HashSet[int]]::new()
|
||||||
|
$script:sourceRTSPPort = Get-FreeTcpPort $reservedPorts
|
||||||
|
$script:sourceAPIPort = Get-FreeTcpPort $reservedPorts
|
||||||
|
$productionRTSPPort = Get-FreeTcpPort $reservedPorts
|
||||||
|
$script:productionAPIPort = Get-FreeTcpPort $reservedPorts
|
||||||
|
$productionMetricsPort = Get-FreeTcpPort $reservedPorts
|
||||||
|
$script:senseHTTPPort = Get-FreeTcpPort $reservedPorts
|
||||||
|
$postgresPort = Get-FreeTcpPort $reservedPorts
|
||||||
|
|
||||||
|
$sourceConfig = Join-Path $script:session 'mediamtx-source.yml'
|
||||||
|
$productionConfig = Join-Path $script:session 'mediamtx-production.yml'
|
||||||
|
$sourceLines = [Collections.Generic.List[string]]::new()
|
||||||
|
@(
|
||||||
|
'logLevel: warn',
|
||||||
|
"rtspAddress: 127.0.0.1:$script:sourceRTSPPort",
|
||||||
|
'rtspTransports: [tcp]',
|
||||||
|
'api: true',
|
||||||
|
"apiAddress: 127.0.0.1:$script:sourceAPIPort",
|
||||||
|
'metrics: false', 'rtmp: false', 'hls: false', 'webrtc: false', 'srt: false', 'moq: false',
|
||||||
|
'paths:'
|
||||||
|
) | ForEach-Object { $sourceLines.Add($_) }
|
||||||
|
foreach ($index in 1..$sourceCount) {
|
||||||
|
$sourceLines.Add((' synthetic-{0:D2}:' -f $index))
|
||||||
|
$sourceLines.Add(' source: publisher')
|
||||||
|
}
|
||||||
|
[IO.File]::WriteAllLines($sourceConfig, $sourceLines, [Text.UTF8Encoding]::new($false))
|
||||||
|
[IO.File]::WriteAllLines($productionConfig, @(
|
||||||
|
'logLevel: warn',
|
||||||
|
"rtspAddress: 127.0.0.1:$productionRTSPPort",
|
||||||
|
'rtspTransports: [tcp]',
|
||||||
|
'api: true',
|
||||||
|
"apiAddress: 127.0.0.1:$script:productionAPIPort",
|
||||||
|
'metrics: true',
|
||||||
|
"metricsAddress: 127.0.0.1:$productionMetricsPort",
|
||||||
|
'rtmp: false', 'hls: false', 'webrtc: false', 'srt: false', 'moq: false',
|
||||||
|
'paths: {}'
|
||||||
|
), [Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
|
$script:fixturePath = Join-Path $script:session 'fixture.mp4'
|
||||||
|
$senseBinary = Join-Path $script:session 'sense-api.exe'
|
||||||
|
$authPath = Join-Path $script:session 'control-auth.json'
|
||||||
|
$cursorPath = Join-Path $script:session 'cursor.key'
|
||||||
|
$pgData = Join-Path $script:session 'pgdata'
|
||||||
|
$pgLog = Join-Path $script:session 'postgres.log'
|
||||||
|
$databaseName = 'yovision_t014'
|
||||||
|
$adminRootDSN = "postgres://postgres@127.0.0.1:$postgresPort/postgres?sslmode=disable"
|
||||||
|
$script:adminDatabaseDSN = "postgres://postgres@127.0.0.1:$postgresPort/${databaseName}?sslmode=disable"
|
||||||
|
$senseDSN = "postgres://yovision_t014_sense@127.0.0.1:$postgresPort/${databaseName}?sslmode=disable"
|
||||||
|
$existing5432 = @(
|
||||||
|
Get-NetTCPConnection -State Listen -LocalPort 5432 -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -ExpandProperty OwningProcess -Unique | Sort-Object
|
||||||
|
)
|
||||||
|
|
||||||
|
$managed = [Collections.Generic.List[object]]::new()
|
||||||
|
$script:publishers = @{}
|
||||||
|
$postgresStarted = $false
|
||||||
|
$postgresStopped = $false
|
||||||
|
$success = $false
|
||||||
|
$stage = 'preparing fixtures'
|
||||||
|
$failure = $null
|
||||||
|
$resultJSON = $null
|
||||||
|
try {
|
||||||
|
Invoke-Checked $script:ffmpeg '-hide_banner' '-loglevel' 'error' '-f' 'lavfi' '-i' 'testsrc2=size=640x360:rate=10' '-t' '30' '-c:v' 'libx264' '-preset' 'ultrafast' '-tune' 'zerolatency' '-pix_fmt' 'yuv420p' '-g' '10' '-an' '-movflags' '+faststart' '-y' $script:fixturePath
|
||||||
|
Invoke-Checked 'go' '-C' $senseDirectory 'build' '-o' $senseBinary './cmd/sense-api'
|
||||||
|
|
||||||
|
$stage = 'starting isolated PostgreSQL'
|
||||||
|
New-Item -ItemType Directory -Path $pgData | Out-Null
|
||||||
|
Invoke-Checked $script:initdb '-D' $pgData '-U' 'postgres' '-A' 'trust' '--encoding=UTF8' '--no-locale' '--no-sync'
|
||||||
|
$serverOptions = "-h 127.0.0.1 -p $postgresPort -c listen_addresses=127.0.0.1"
|
||||||
|
# Do not pipe pg_ctl start output: postgres can inherit the pipeline handle and
|
||||||
|
# keep PowerShell waiting until the server exits.
|
||||||
|
& $script:pgCtl '-D' $pgData '-l' $pgLog '-o' $serverOptions '-w' 'start'
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "required command failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
$postgresStarted = $true
|
||||||
|
Invoke-Checked $script:psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-f' (Join-Path $script:repoRoot 'deploy\postgres\001_roles.sql')
|
||||||
|
Invoke-Checked $script:createdb '-h' '127.0.0.1' '-p' ([string]$postgresPort) '-U' 'postgres' $databaseName
|
||||||
|
foreach ($pass in 1..2) {
|
||||||
|
foreach ($name in $migrationNames) {
|
||||||
|
Invoke-Checked $script:psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $script:adminDatabaseDSN '-f' (Join-Path $script:repoRoot "deploy\postgres\$name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Invoke-Checked $script:psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-c' 'CREATE ROLE yovision_t014_sense LOGIN IN ROLE sense_app'
|
||||||
|
Invoke-Checked $script:psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $script:adminDatabaseDSN '-c' "INSERT INTO bell.sites(tenant_id,id,name,max_video_channels) VALUES ('tenant-t014','site-t014','T-014 Lab',16); INSERT INTO bell.areas(tenant_id,site_id,id,name,capture_policy) VALUES ('tenant-t014','site-t014','area-t014','T-014 Area','video_allowed');"
|
||||||
|
|
||||||
|
$stage = 'starting independent publishers'
|
||||||
|
$sourceMedia = Start-ManagedProcess 'mediamtx-source' $mediaMTX @($sourceConfig)
|
||||||
|
$managed.Add($sourceMedia)
|
||||||
|
Wait-Port $script:sourceAPIPort
|
||||||
|
foreach ($index in 1..$sourceCount) {
|
||||||
|
$script:publishers[$index] = Start-Publisher $index
|
||||||
|
$managed.Add($script:publishers[$index])
|
||||||
|
}
|
||||||
|
$publisherReadySeconds = Wait-SourcePublishers
|
||||||
|
$productionMedia = Start-ManagedProcess 'mediamtx-production' $mediaMTX @($productionConfig)
|
||||||
|
$managed.Add($productionMedia)
|
||||||
|
Wait-Port $script:productionAPIPort
|
||||||
|
|
||||||
|
$stage = 'starting Sense Control API'
|
||||||
|
$script:controlToken = New-SecretToken
|
||||||
|
$tokenDigest = Get-SHA256Hex $script:controlToken
|
||||||
|
$cursorKey = ConvertTo-Base64Url (New-RandomBytes 32)
|
||||||
|
$localCredential = New-SecretToken
|
||||||
|
[IO.File]::WriteAllText($authPath, (([ordered]@{
|
||||||
|
version = 1
|
||||||
|
principals = @([ordered]@{
|
||||||
|
token_sha256 = $tokenDigest
|
||||||
|
subject_id = 't014-runner'
|
||||||
|
actor_type = 'service'
|
||||||
|
tenant_id = 'tenant-t014'
|
||||||
|
site_ids = @('site-t014')
|
||||||
|
permissions = @('sense.devices.read', 'sense.devices.write')
|
||||||
|
})
|
||||||
|
} | ConvertTo-Json -Depth 6) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false))
|
||||||
|
[IO.File]::WriteAllText($cursorPath, $cursorKey + [Environment]::NewLine, [Text.UTF8Encoding]::new($false))
|
||||||
|
$senseEnvironment = @{
|
||||||
|
SENSE_HTTP_ADDR = "127.0.0.1:$script:senseHTTPPort"
|
||||||
|
SENSE_DB_DRIVER = 'postgres'
|
||||||
|
SENSE_DB_DSN = $senseDSN
|
||||||
|
SENSE_MEDIAMTX_URL = "http://127.0.0.1:$script:productionAPIPort"
|
||||||
|
SENSE_RECONCILE_INTERVAL = '1s'
|
||||||
|
SENSE_RECONCILE_LEASE_DURATION = '30s'
|
||||||
|
SENSE_RECONCILE_OPERATION_TIMEOUT = '20s'
|
||||||
|
SENSE_PROBE_INTERVAL = '1s'
|
||||||
|
SENSE_INSTANCE_ID = 't014-lab'
|
||||||
|
SENSE_METRICS_ENABLED = 'true'
|
||||||
|
SENSE_ORPHAN_SCAN_ENABLED = 'false'
|
||||||
|
SENSE_ONVIF_MODE = 'disabled'
|
||||||
|
SENSE_CONTROL_API_ENABLED = 'true'
|
||||||
|
SENSE_CONTROL_AUTH_FILE = $authPath
|
||||||
|
SENSE_CONTROL_CURSOR_KEY_FILE = $cursorPath
|
||||||
|
SENSE_CREDENTIAL_SYNTHETIC_ONVIF_USERNAME = 't014-local'
|
||||||
|
SENSE_CREDENTIAL_SYNTHETIC_ONVIF_PASSWORD = $localCredential
|
||||||
|
SENSE_CREDENTIAL_SYNTHETIC_RTSP_USERNAME = 't014-local'
|
||||||
|
SENSE_CREDENTIAL_SYNTHETIC_RTSP_PASSWORD = $localCredential
|
||||||
|
}
|
||||||
|
$sense = Start-ManagedProcess 'sense-api' $senseBinary @() $senseEnvironment
|
||||||
|
$managed.Add($sense)
|
||||||
|
Wait-SenseHealth $sense
|
||||||
|
|
||||||
|
$stage = 'creating 16 disabled devices'
|
||||||
|
$deviceIDs = [Collections.Generic.List[string]]::new()
|
||||||
|
foreach ($index in 1..$sourceCount) {
|
||||||
|
$name = 'synthetic-{0:D2}' -f $index
|
||||||
|
$response = Invoke-ControlRequest 'POST' '/api/v1/sites/site-t014/devices' @(201) ([ordered]@{
|
||||||
|
serial_number = "t014-$name"
|
||||||
|
name = "T-014 source $index"
|
||||||
|
modality = 'video'
|
||||||
|
capabilities = @('video_capture')
|
||||||
|
area_id = 'area-t014'
|
||||||
|
endpoint_ref = "rtsp://127.0.0.1:$script:sourceRTSPPort/$name"
|
||||||
|
credential_ref = 'env://synthetic'
|
||||||
|
desired_state = 'disabled'
|
||||||
|
}) @{ 'Idempotency-Key' = "t014-create-$index-$script:runNonce" }
|
||||||
|
$created = Convert-ResponseJSON $response
|
||||||
|
$deviceIDs.Add([string]$created.id)
|
||||||
|
}
|
||||||
|
$stage = 'batch enabling 16 devices'
|
||||||
|
$firstEnableEntries = Get-DeviceEntries @($deviceIDs)
|
||||||
|
$firstEnableBatch = Invoke-BatchDesiredState 'enabled' $firstEnableEntries 'enable-1'
|
||||||
|
$firstEnableSeconds = Wait-Convergence 'enabled'
|
||||||
|
$firstEnabledInventory = Get-MediaInventory $script:productionAPIPort
|
||||||
|
|
||||||
|
$stage = 'rejecting seventeenth enabled device'
|
||||||
|
$overflowResponse = Invoke-ControlRequest 'POST' '/api/v1/sites/site-t014/devices' @(409) ([ordered]@{
|
||||||
|
serial_number = 't014-overflow-17'
|
||||||
|
name = 'T-014 overflow source'
|
||||||
|
modality = 'video'
|
||||||
|
capabilities = @('video_capture')
|
||||||
|
area_id = 'area-t014'
|
||||||
|
endpoint_ref = "rtsp://127.0.0.1:$script:sourceRTSPPort/synthetic-01"
|
||||||
|
credential_ref = 'env://synthetic'
|
||||||
|
desired_state = 'enabled'
|
||||||
|
}) @{ 'Idempotency-Key' = "t014-overflow-17-$script:runNonce" }
|
||||||
|
$overflowProblem = Convert-ResponseJSON $overflowResponse
|
||||||
|
$overflowCodeProperty = $overflowProblem.PSObject.Properties['code']
|
||||||
|
if ($null -eq $overflowCodeProperty) {
|
||||||
|
$propertyNames = @($overflowProblem.PSObject.Properties.Name) -join ','
|
||||||
|
throw "quota response did not contain a stable code field; properties=$propertyNames"
|
||||||
|
}
|
||||||
|
$overflowCode = [string]$overflowCodeProperty.Value
|
||||||
|
$pageAfterOverflow = Get-DevicePage
|
||||||
|
if ($overflowCode -ne 'quota_exceeded' -or @($pageAfterOverflow.items).Count -ne $sourceCount) {
|
||||||
|
throw 'the seventeenth enabled device did not fail closed at the site quota'
|
||||||
|
}
|
||||||
|
|
||||||
|
$stage = 'batch disabling 16 devices'
|
||||||
|
$disableEntries = Get-DeviceEntries @($deviceIDs)
|
||||||
|
$disableBatch = Invoke-BatchDesiredState 'disabled' $disableEntries 'disable'
|
||||||
|
$disableSeconds = Wait-Convergence 'disabled'
|
||||||
|
$disabledInventory = Get-MediaInventory $script:productionAPIPort
|
||||||
|
|
||||||
|
$stage = 'batch re-enabling 16 devices'
|
||||||
|
$secondEnableEntries = Get-DeviceEntries @($deviceIDs)
|
||||||
|
$secondEnableBatch = Invoke-BatchDesiredState 'enabled' $secondEnableEntries 'enable-2'
|
||||||
|
$secondEnableSeconds = Wait-Convergence 'enabled'
|
||||||
|
$secondEnabledInventory = Get-MediaInventory $script:productionAPIPort
|
||||||
|
|
||||||
|
$stage = 'injecting four-source fault'
|
||||||
|
foreach ($index in $faultIndexes) {
|
||||||
|
Stop-ManagedProcess $script:publishers[$index]
|
||||||
|
}
|
||||||
|
$fault = Wait-PartialFault
|
||||||
|
foreach ($index in $faultIndexes) {
|
||||||
|
$script:publishers[$index] = Start-Publisher $index
|
||||||
|
$managed.Add($script:publishers[$index])
|
||||||
|
}
|
||||||
|
$faultRecoverySeconds = Wait-Convergence 'enabled'
|
||||||
|
$fault.recovery_seconds = $faultRecoverySeconds
|
||||||
|
$fault.final_unconverged = 0
|
||||||
|
|
||||||
|
$stage = 'observing stable 16-stream capacity'
|
||||||
|
$observation = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$targetSeconds = $ObservationMinutes * 60
|
||||||
|
$senseCPU = [Collections.Generic.List[double]]::new()
|
||||||
|
$senseWorking = [Collections.Generic.List[double]]::new()
|
||||||
|
$sensePrivate = [Collections.Generic.List[double]]::new()
|
||||||
|
$senseHandles = [Collections.Generic.List[double]]::new()
|
||||||
|
$mediaCPU = [Collections.Generic.List[double]]::new()
|
||||||
|
$mediaWorking = [Collections.Generic.List[double]]::new()
|
||||||
|
$mediaPrivate = [Collections.Generic.List[double]]::new()
|
||||||
|
$mediaHandles = [Collections.Generic.List[double]]::new()
|
||||||
|
$databaseConnections = [Collections.Generic.List[double]]::new()
|
||||||
|
$previousSenseCPU = $sense.Process.TotalProcessorTime.TotalSeconds
|
||||||
|
$previousMediaCPU = $productionMedia.Process.TotalProcessorTime.TotalSeconds
|
||||||
|
$observationSenseCPUStart = $previousSenseCPU
|
||||||
|
$observationMediaCPUStart = $previousMediaCPU
|
||||||
|
$previousSampleAt = 0.0
|
||||||
|
$initialInventory = Get-MediaInventory $script:productionAPIPort
|
||||||
|
$samples = 0
|
||||||
|
$requiredSamples = [int][Math]::Ceiling($targetSeconds / $sampleSeconds)
|
||||||
|
$maximumUnconverged = 0
|
||||||
|
while ($samples -lt $requiredSamples) {
|
||||||
|
$nextSampleAt = ($samples + 1) * $sampleSeconds
|
||||||
|
$delayMilliseconds = [int][Math]::Ceiling(($nextSampleAt - $observation.Elapsed.TotalSeconds) * 1000)
|
||||||
|
if ($delayMilliseconds -gt 0) {
|
||||||
|
Start-Sleep -Milliseconds $delayMilliseconds
|
||||||
|
}
|
||||||
|
Assert-Alive $sourceMedia
|
||||||
|
Assert-Alive $productionMedia
|
||||||
|
Assert-Alive $sense
|
||||||
|
foreach ($publisher in $script:publishers.Values) { Assert-Alive $publisher }
|
||||||
|
$elapsed = $observation.Elapsed.TotalSeconds
|
||||||
|
$delta = $elapsed - $previousSampleAt
|
||||||
|
$senseSample = Get-ProcessResourceSample $sense $previousSenseCPU $delta
|
||||||
|
$mediaSample = Get-ProcessResourceSample $productionMedia $previousMediaCPU $delta
|
||||||
|
$previousSenseCPU = $senseSample.TotalCPU
|
||||||
|
$previousMediaCPU = $mediaSample.TotalCPU
|
||||||
|
$previousSampleAt = $elapsed
|
||||||
|
$senseCPU.Add($senseSample.CPUPercent)
|
||||||
|
$senseWorking.Add($senseSample.WorkingSetMiB)
|
||||||
|
$sensePrivate.Add($senseSample.PrivateMiB)
|
||||||
|
$senseHandles.Add($senseSample.Handles)
|
||||||
|
$mediaCPU.Add($mediaSample.CPUPercent)
|
||||||
|
$mediaWorking.Add($mediaSample.WorkingSetMiB)
|
||||||
|
$mediaPrivate.Add($mediaSample.PrivateMiB)
|
||||||
|
$mediaHandles.Add($mediaSample.Handles)
|
||||||
|
$databaseConnections.Add([double](Get-PostgresScalar "SELECT count(*) FROM pg_stat_activity WHERE datname = '$databaseName'"))
|
||||||
|
$page = Get-DevicePage
|
||||||
|
$inventory = Get-MediaInventory $script:productionAPIPort
|
||||||
|
$unconverged = [int](Get-SenseMetric 'sense_reconcile_devices' '{state="unconverged"}')
|
||||||
|
$maximumUnconverged = [Math]::Max($maximumUnconverged, $unconverged)
|
||||||
|
if (@($page.items).Count -ne $sourceCount -or
|
||||||
|
@($page.items | Where-Object { $_.desired_state -ne 'enabled' -or $_.actual_state -ne 'online' -or -not $_.converged }).Count -ne 0 -or
|
||||||
|
$inventory.Configured -ne $sourceCount -or $inventory.Runtime -ne $sourceCount -or
|
||||||
|
$inventory.Online -ne $sourceCount -or $unconverged -ne 0) {
|
||||||
|
throw 'stability observation detected a non-converged stream'
|
||||||
|
}
|
||||||
|
$samples++
|
||||||
|
}
|
||||||
|
$observationSeconds = [Math]::Round($observation.Elapsed.TotalSeconds, 1)
|
||||||
|
$finalInventory = Get-MediaInventory $script:productionAPIPort
|
||||||
|
$sense.Process.Refresh()
|
||||||
|
$productionMedia.Process.Refresh()
|
||||||
|
$senseCPUTotal = $sense.Process.TotalProcessorTime.TotalSeconds - $observationSenseCPUStart
|
||||||
|
$mediaCPUTotal = $productionMedia.Process.TotalProcessorTime.TotalSeconds - $observationMediaCPUStart
|
||||||
|
$inboundDelta = [UInt64]($finalInventory.InboundBytes - $initialInventory.InboundBytes)
|
||||||
|
$frameErrorDelta = [UInt64]($finalInventory.FrameErrors - $initialInventory.FrameErrors)
|
||||||
|
$inboundMbps = if ($observationSeconds -gt 0) { ($inboundDelta * 8.0) / $observationSeconds / 1000000.0 } else { 0.0 }
|
||||||
|
$databaseSizeBytes = [Int64](Get-PostgresScalar "SELECT pg_database_size('$databaseName')")
|
||||||
|
$finalUnconverged = [int](Get-SenseMetric 'sense_reconcile_devices' '{state="unconverged"}')
|
||||||
|
if ($observationSeconds -lt $targetSeconds -or $finalUnconverged -ne 0 -or
|
||||||
|
$maximumUnconverged -ne 0 -or $samples -lt $requiredSamples) {
|
||||||
|
throw 'formal observation did not satisfy sampling and convergence requirements'
|
||||||
|
}
|
||||||
|
|
||||||
|
$memoryGiB = 0.0
|
||||||
|
try { $memoryGiB = (Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB } catch {}
|
||||||
|
$gitCommit = (& git -C $script:repoRoot rev-parse HEAD | Out-String).Trim()
|
||||||
|
$success = $true
|
||||||
|
$resultJSON = [ordered]@{
|
||||||
|
success = $true
|
||||||
|
formal_eligible = $ObservationMinutes -ge 30
|
||||||
|
scope = 'laboratory_software_baseline'
|
||||||
|
source_count = $sourceCount
|
||||||
|
independent_publishers = $sourceCount
|
||||||
|
fixture = [ordered]@{ width = 640; height = 360; fps = 10; codec = 'H.264'; audio = $false; publish_mode = 'preencoded_copy' }
|
||||||
|
versions = [ordered]@{
|
||||||
|
repository_commit = $gitCommit
|
||||||
|
sense_sha256 = (Get-FileHash -LiteralPath $senseBinary -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
mediamtx = $mediaMTXVersion
|
||||||
|
mediamtx_package_sha256 = $mediaMTXSHA256
|
||||||
|
mediamtx_executable_sha256 = $mediaMTXExecutableSHA256
|
||||||
|
postgres = $postgresVersion
|
||||||
|
ffmpeg = [string]$ffmpegVersion
|
||||||
|
go = $goVersion
|
||||||
|
}
|
||||||
|
host = [ordered]@{ logical_processors = [Environment]::ProcessorCount; total_memory_gib = [Math]::Round($memoryGiB, 2) }
|
||||||
|
quota = [ordered]@{ configured = 16; accepted_devices = 16; seventeenth_rejected = $true; error_code = 'quota_exceeded' }
|
||||||
|
publishers_ready_seconds = $publisherReadySeconds
|
||||||
|
batch = [ordered]@{
|
||||||
|
enable_first = $firstEnableBatch
|
||||||
|
disable = $disableBatch
|
||||||
|
enable_second = $secondEnableBatch
|
||||||
|
}
|
||||||
|
convergence_seconds = [ordered]@{
|
||||||
|
enable_first = $firstEnableSeconds
|
||||||
|
disable = $disableSeconds
|
||||||
|
enable_second = $secondEnableSeconds
|
||||||
|
}
|
||||||
|
configured_paths = [ordered]@{
|
||||||
|
after_enable_first = $firstEnabledInventory.Configured
|
||||||
|
after_disable = $disabledInventory.Configured
|
||||||
|
after_enable_second = $secondEnabledInventory.Configured
|
||||||
|
}
|
||||||
|
fault_recovery = $fault
|
||||||
|
observation = [ordered]@{
|
||||||
|
requested_minutes = $ObservationMinutes
|
||||||
|
seconds = $observationSeconds
|
||||||
|
sample_interval_seconds = $sampleSeconds
|
||||||
|
samples = $samples
|
||||||
|
maximum_unconverged = $maximumUnconverged
|
||||||
|
final_unconverged = $finalUnconverged
|
||||||
|
final_online_paths = $finalInventory.Online
|
||||||
|
aggregate_inbound_mbps = [Math]::Round($inboundMbps, 3)
|
||||||
|
inbound_frame_errors = $frameErrorDelta
|
||||||
|
}
|
||||||
|
resources = [ordered]@{
|
||||||
|
sense = [ordered]@{
|
||||||
|
cpu_average_percent = [Math]::Round((Get-Average $senseCPU), 3)
|
||||||
|
cpu_peak_percent = [Math]::Round((Get-Maximum $senseCPU), 3)
|
||||||
|
cpu_total_seconds = [Math]::Round($senseCPUTotal, 3)
|
||||||
|
working_set_peak_mib = [Math]::Round((Get-Maximum $senseWorking), 2)
|
||||||
|
private_peak_mib = [Math]::Round((Get-Maximum $sensePrivate), 2)
|
||||||
|
handles_peak = [int](Get-Maximum $senseHandles)
|
||||||
|
}
|
||||||
|
mediamtx = [ordered]@{
|
||||||
|
cpu_average_percent = [Math]::Round((Get-Average $mediaCPU), 3)
|
||||||
|
cpu_peak_percent = [Math]::Round((Get-Maximum $mediaCPU), 3)
|
||||||
|
cpu_total_seconds = [Math]::Round($mediaCPUTotal, 3)
|
||||||
|
working_set_peak_mib = [Math]::Round((Get-Maximum $mediaWorking), 2)
|
||||||
|
private_peak_mib = [Math]::Round((Get-Maximum $mediaPrivate), 2)
|
||||||
|
handles_peak = [int](Get-Maximum $mediaHandles)
|
||||||
|
}
|
||||||
|
postgres_peak_connections = [int](Get-Maximum $databaseConnections)
|
||||||
|
postgres_database_mib = [Math]::Round($databaseSizeBytes / 1MB, 2)
|
||||||
|
}
|
||||||
|
limitations = @('synthetic_sources', 'no_customer_network', 'no_recording', 'no_downstream_readers', 'no_ai_or_gpu', 'not_a_production_sla')
|
||||||
|
} | ConvertTo-Json -Depth 12
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$failure = "T-014 stage '$stage' failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Stop-AllManagedProcesses @($managed)
|
||||||
|
if ($postgresStarted) {
|
||||||
|
& $script:pgCtl '-D' $pgData '-m' 'fast' '-w' 'stop' | Out-Null
|
||||||
|
$postgresStopped = $LASTEXITCODE -eq 0
|
||||||
|
}
|
||||||
|
if (-not $postgresStarted -or $postgresStopped) {
|
||||||
|
$resolvedSession = [IO.Path]::GetFullPath($script:session)
|
||||||
|
$expectedPrefix = $runtimeResolved.TrimEnd('\') + '\'
|
||||||
|
if (-not $resolvedSession.StartsWith($expectedPrefix, [StringComparison]::OrdinalIgnoreCase) -or
|
||||||
|
[IO.Path]::GetFileName($resolvedSession) -notlike 'session-*') {
|
||||||
|
throw 'refusing to clean an unexpected T-014 session path'
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $resolvedSession) {
|
||||||
|
Remove-SessionDirectory $resolvedSession
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Warning 'Temporary PostgreSQL did not stop; the session directory was retained for manual recovery'
|
||||||
|
}
|
||||||
|
$after5432 = @(
|
||||||
|
Get-NetTCPConnection -State Listen -LocalPort 5432 -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -ExpandProperty OwningProcess -Unique | Sort-Object
|
||||||
|
)
|
||||||
|
if (($existing5432 -join ',') -ne ($after5432 -join ',')) {
|
||||||
|
throw 'the existing PostgreSQL listener on port 5432 changed during T-014'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $success) {
|
||||||
|
throw $failure
|
||||||
|
}
|
||||||
|
Write-Result $resultJSON
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- T-009 cluster roles. Runtime login roles are deployment-specific members of
|
||||||
|
-- these NOLOGIN roles; passwords and login principals never belong in Git.
|
||||||
|
|
||||||
|
DO $roles$
|
||||||
|
DECLARE
|
||||||
|
role_record pg_roles%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO role_record FROM pg_roles WHERE rolname = 'bell_app';
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
CREATE ROLE bell_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
|
||||||
|
ELSIF role_record.rolsuper OR role_record.rolcreatedb OR role_record.rolcreaterole
|
||||||
|
OR role_record.rolreplication OR role_record.rolcanlogin THEN
|
||||||
|
RAISE EXCEPTION 'existing bell_app role has unsafe attributes';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT * INTO role_record FROM pg_roles WHERE rolname = 'sense_app';
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
CREATE ROLE sense_app NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
|
||||||
|
ELSIF role_record.rolsuper OR role_record.rolcreatedb OR role_record.rolcreaterole
|
||||||
|
OR role_record.rolreplication OR role_record.rolcanlogin THEN
|
||||||
|
RAISE EXCEPTION 'existing sense_app role has unsafe attributes';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$roles$;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- Bell owns Tenant/Site/quota truth. Run as the cluster administrator or a
|
||||||
|
-- migration role that can SET ROLE to bell_app.
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS bell AUTHORIZATION bell_app;
|
||||||
|
ALTER SCHEMA bell OWNER TO bell_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bell.schema_migrations (
|
||||||
|
version bigint PRIMARY KEY,
|
||||||
|
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
ALTER TABLE bell.schema_migrations OWNER TO bell_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bell.sites (
|
||||||
|
tenant_id text NOT NULL,
|
||||||
|
id text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
max_video_channels smallint NOT NULL DEFAULT 16,
|
||||||
|
version bigint NOT NULL DEFAULT 1,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
PRIMARY KEY (tenant_id, id),
|
||||||
|
CONSTRAINT bell_sites_video_quota_range
|
||||||
|
CHECK (max_video_channels BETWEEN 1 AND 128),
|
||||||
|
CONSTRAINT bell_sites_version_positive CHECK (version >= 1),
|
||||||
|
CONSTRAINT bell_sites_identity_not_blank
|
||||||
|
CHECK (btrim(tenant_id) <> '' AND btrim(id) <> '' AND btrim(name) <> '')
|
||||||
|
);
|
||||||
|
ALTER TABLE bell.sites OWNER TO bell_app;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION bell.bump_site_version()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = pg_catalog, bell
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id OR NEW.id IS DISTINCT FROM OLD.id THEN
|
||||||
|
RAISE EXCEPTION 'Bell site identity is immutable';
|
||||||
|
END IF;
|
||||||
|
NEW.version := OLD.version + 1;
|
||||||
|
NEW.updated_at := clock_timestamp();
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$function$;
|
||||||
|
ALTER FUNCTION bell.bump_site_version() OWNER TO bell_app;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS bell_sites_bump_version ON bell.sites;
|
||||||
|
CREATE TRIGGER bell_sites_bump_version
|
||||||
|
BEFORE UPDATE ON bell.sites
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION bell.bump_site_version();
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW bell.site_quota_v1 (
|
||||||
|
tenant_id,
|
||||||
|
site_id,
|
||||||
|
max_video_channels,
|
||||||
|
source_version,
|
||||||
|
source_updated_at
|
||||||
|
) AS
|
||||||
|
SELECT
|
||||||
|
site.tenant_id,
|
||||||
|
site.id,
|
||||||
|
site.max_video_channels,
|
||||||
|
site.version,
|
||||||
|
site.updated_at
|
||||||
|
FROM bell.sites AS site
|
||||||
|
WHERE site.deleted_at IS NULL;
|
||||||
|
ALTER VIEW bell.site_quota_v1 OWNER TO bell_app;
|
||||||
|
COMMENT ON VIEW bell.site_quota_v1 IS
|
||||||
|
'v1 read-only site video quota projection owned by Bell and consumed by Sense';
|
||||||
|
|
||||||
|
INSERT INTO bell.schema_migrations(version) VALUES (1)
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
-- Sense owns device desired state and reconciliation progress. It deliberately
|
||||||
|
-- has no writable Site or quota truth table.
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS sense AUTHORIZATION sense_app;
|
||||||
|
ALTER SCHEMA sense OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.schema_migrations (
|
||||||
|
version bigint PRIMARY KEY,
|
||||||
|
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.schema_migrations OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.devices (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
tenant_id text NOT NULL,
|
||||||
|
site_id text NOT NULL,
|
||||||
|
serial_number text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
modality text NOT NULL,
|
||||||
|
desired_state text NOT NULL,
|
||||||
|
actual_state text NOT NULL,
|
||||||
|
endpoint_ref text NOT NULL DEFAULT '',
|
||||||
|
credential_ref text NOT NULL DEFAULT '',
|
||||||
|
path_name text NOT NULL DEFAULT '',
|
||||||
|
generation bigint NOT NULL DEFAULT 1,
|
||||||
|
quota_source_version bigint,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT sense_devices_identity_not_blank CHECK (
|
||||||
|
btrim(id) <> '' AND btrim(tenant_id) <> '' AND btrim(site_id) <> ''
|
||||||
|
AND btrim(serial_number) <> '' AND btrim(name) <> ''
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_devices_modality CHECK (
|
||||||
|
modality IN ('video', 'radar', 'contact', 'button', 'wearable', 'other')
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_devices_desired_state CHECK (desired_state IN ('disabled', 'enabled')),
|
||||||
|
CONSTRAINT sense_devices_actual_state CHECK (actual_state IN ('pending', 'online', 'offline', 'failed')),
|
||||||
|
CONSTRAINT sense_devices_generation_positive CHECK (generation >= 1),
|
||||||
|
CONSTRAINT sense_devices_quota_version_positive CHECK (
|
||||||
|
quota_source_version IS NULL OR quota_source_version >= 1
|
||||||
|
),
|
||||||
|
UNIQUE (tenant_id, site_id, serial_number)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.devices OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.device_capabilities (
|
||||||
|
device_id text NOT NULL REFERENCES sense.devices(id) ON DELETE CASCADE,
|
||||||
|
capability text NOT NULL,
|
||||||
|
PRIMARY KEY (device_id, capability),
|
||||||
|
CONSTRAINT sense_device_capability_known CHECK (
|
||||||
|
capability IN ('video_capture', 'audio_capture', 'spatial_rule', 'telemetry')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.device_capabilities OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.reconcile_state (
|
||||||
|
device_id text PRIMARY KEY REFERENCES sense.devices(id) ON DELETE CASCADE,
|
||||||
|
failure_count integer NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at timestamptz,
|
||||||
|
last_error_code text,
|
||||||
|
observed_generation bigint NOT NULL DEFAULT 0,
|
||||||
|
updated_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT sense_reconcile_failure_nonnegative CHECK (failure_count >= 0),
|
||||||
|
CONSTRAINT sense_reconcile_generation_nonnegative CHECK (observed_generation >= 0)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.reconcile_state OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.site_quota_projection_state (
|
||||||
|
tenant_id text NOT NULL,
|
||||||
|
site_id text NOT NULL,
|
||||||
|
source_version bigint NOT NULL,
|
||||||
|
synced_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (tenant_id, site_id),
|
||||||
|
CONSTRAINT sense_quota_projection_version_positive CHECK (source_version >= 1)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.site_quota_projection_state OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sense_devices_site_state_idx
|
||||||
|
ON sense.devices(tenant_id, site_id, desired_state);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS sense_devices_path_name_idx
|
||||||
|
ON sense.devices(path_name) WHERE path_name <> '';
|
||||||
|
CREATE INDEX IF NOT EXISTS sense_reconcile_due_idx
|
||||||
|
ON sense.reconcile_state(next_attempt_at);
|
||||||
|
|
||||||
|
INSERT INTO sense.schema_migrations(version) VALUES (1)
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Cross-schema privileges are intentionally one-way: Bell publishes one view;
|
||||||
|
-- Sense cannot read Bell source tables or write any Bell object.
|
||||||
|
|
||||||
|
REVOKE ALL ON SCHEMA bell FROM PUBLIC;
|
||||||
|
REVOKE ALL ON ALL TABLES IN SCHEMA bell FROM PUBLIC;
|
||||||
|
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA bell FROM PUBLIC;
|
||||||
|
REVOKE ALL ON SCHEMA sense FROM PUBLIC;
|
||||||
|
REVOKE ALL ON ALL TABLES IN SCHEMA sense FROM PUBLIC;
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA bell TO sense_app;
|
||||||
|
GRANT SELECT ON TABLE bell.site_quota_v1 TO sense_app;
|
||||||
|
REVOKE ALL ON TABLE bell.sites FROM sense_app;
|
||||||
|
REVOKE ALL ON TABLE bell.schema_migrations FROM sense_app;
|
||||||
|
REVOKE ALL ON FUNCTION bell.bump_site_version() FROM sense_app;
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA sense TO sense_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA sense TO sense_app;
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE bell_app IN SCHEMA bell REVOKE ALL ON TABLES FROM PUBLIC;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE bell_app IN SCHEMA bell REVOKE ALL ON FUNCTIONS FROM PUBLIC;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE sense_app IN SCHEMA sense REVOKE ALL ON TABLES FROM PUBLIC;
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
-- Bell owns Area and capture-policy truth. Sense records only the highest
|
||||||
|
-- projection version it has observed and the version used for admission.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bell.areas (
|
||||||
|
tenant_id text NOT NULL,
|
||||||
|
site_id text NOT NULL,
|
||||||
|
id text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
capture_policy text NOT NULL DEFAULT 'video_allowed',
|
||||||
|
version bigint NOT NULL DEFAULT 1,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
PRIMARY KEY (tenant_id, id),
|
||||||
|
CONSTRAINT bell_areas_site_fk FOREIGN KEY (tenant_id, site_id)
|
||||||
|
REFERENCES bell.sites(tenant_id, id),
|
||||||
|
CONSTRAINT bell_areas_capture_policy CHECK (
|
||||||
|
capture_policy IN ('video_allowed', 'non_imaging_only')
|
||||||
|
),
|
||||||
|
CONSTRAINT bell_areas_version_positive CHECK (version >= 1),
|
||||||
|
CONSTRAINT bell_areas_identity_not_blank CHECK (
|
||||||
|
btrim(tenant_id) <> '' AND btrim(site_id) <> ''
|
||||||
|
AND btrim(id) <> '' AND btrim(name) <> ''
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE bell.areas OWNER TO bell_app;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION bell.bump_area_version()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = pg_catalog, bell
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id
|
||||||
|
OR NEW.site_id IS DISTINCT FROM OLD.site_id
|
||||||
|
OR NEW.id IS DISTINCT FROM OLD.id THEN
|
||||||
|
RAISE EXCEPTION 'Bell Area identity and Site are immutable';
|
||||||
|
END IF;
|
||||||
|
NEW.version := OLD.version + 1;
|
||||||
|
NEW.updated_at := clock_timestamp();
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$function$;
|
||||||
|
ALTER FUNCTION bell.bump_area_version() OWNER TO bell_app;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS bell_areas_bump_version ON bell.areas;
|
||||||
|
CREATE TRIGGER bell_areas_bump_version
|
||||||
|
BEFORE UPDATE ON bell.areas
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION bell.bump_area_version();
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW bell.area_policy_v1 (
|
||||||
|
tenant_id,
|
||||||
|
site_id,
|
||||||
|
area_id,
|
||||||
|
capture_policy,
|
||||||
|
source_version,
|
||||||
|
source_updated_at
|
||||||
|
) AS
|
||||||
|
SELECT
|
||||||
|
area.tenant_id,
|
||||||
|
area.site_id,
|
||||||
|
area.id,
|
||||||
|
area.capture_policy,
|
||||||
|
area.version,
|
||||||
|
area.updated_at
|
||||||
|
FROM bell.areas AS area
|
||||||
|
JOIN bell.sites AS site
|
||||||
|
ON site.tenant_id = area.tenant_id AND site.id = area.site_id
|
||||||
|
WHERE area.deleted_at IS NULL AND site.deleted_at IS NULL;
|
||||||
|
ALTER VIEW bell.area_policy_v1 OWNER TO bell_app;
|
||||||
|
COMMENT ON VIEW bell.area_policy_v1 IS
|
||||||
|
'v1 read-only Area capture-policy projection owned by Bell and consumed by Sense';
|
||||||
|
|
||||||
|
ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS area_id text;
|
||||||
|
ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS area_policy_source_version bigint;
|
||||||
|
|
||||||
|
DO $constraints$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'sense.devices'::regclass
|
||||||
|
AND conname = 'sense_devices_area_not_blank'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sense.devices ADD CONSTRAINT sense_devices_area_not_blank
|
||||||
|
CHECK (area_id IS NULL OR btrim(area_id) <> '');
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'sense.devices'::regclass
|
||||||
|
AND conname = 'sense_devices_area_version_positive'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sense.devices ADD CONSTRAINT sense_devices_area_version_positive
|
||||||
|
CHECK (area_policy_source_version IS NULL OR area_policy_source_version >= 1);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'sense.devices'::regclass
|
||||||
|
AND conname = 'sense_devices_tenant_site_id_unique'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sense.devices ADD CONSTRAINT sense_devices_tenant_site_id_unique
|
||||||
|
UNIQUE (tenant_id, site_id, id);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$constraints$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.area_policy_projection_state (
|
||||||
|
tenant_id text NOT NULL,
|
||||||
|
site_id text NOT NULL,
|
||||||
|
area_id text NOT NULL,
|
||||||
|
source_version bigint NOT NULL,
|
||||||
|
synced_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (tenant_id, site_id, area_id),
|
||||||
|
CONSTRAINT sense_area_projection_identity_not_blank CHECK (
|
||||||
|
btrim(tenant_id) <> '' AND btrim(site_id) <> '' AND btrim(area_id) <> ''
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_area_projection_version_positive CHECK (source_version >= 1)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.area_policy_projection_state OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sense_devices_area_idx
|
||||||
|
ON sense.devices(tenant_id, site_id, area_id);
|
||||||
|
|
||||||
|
INSERT INTO bell.schema_migrations(version) VALUES (2)
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
|
INSERT INTO sense.schema_migrations(version) VALUES (2)
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
-- Local durable audit facts. Transport, signatures, acknowledgements and
|
||||||
|
-- retention belong to a later Bell relay contract.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.device_operation_outbox (
|
||||||
|
event_id text PRIMARY KEY,
|
||||||
|
event_type text NOT NULL,
|
||||||
|
tenant_id text NOT NULL,
|
||||||
|
site_id text NOT NULL,
|
||||||
|
device_id text NOT NULL,
|
||||||
|
actor_type text NOT NULL,
|
||||||
|
actor_id text NOT NULL,
|
||||||
|
reason text,
|
||||||
|
trace_id text,
|
||||||
|
aggregate_generation bigint NOT NULL,
|
||||||
|
quota_source_version bigint,
|
||||||
|
area_policy_source_version bigint,
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
available_at timestamptz NOT NULL,
|
||||||
|
attempt_count integer NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at timestamptz,
|
||||||
|
delivered_at timestamptz,
|
||||||
|
CONSTRAINT sense_outbox_device_fk FOREIGN KEY (tenant_id, site_id, device_id)
|
||||||
|
REFERENCES sense.devices(tenant_id, site_id, id),
|
||||||
|
CONSTRAINT sense_outbox_event_id_format CHECK (event_id ~ '^audit_[0-9a-f]{32}$'),
|
||||||
|
CONSTRAINT sense_outbox_event_type CHECK (
|
||||||
|
event_type IN ('device.created', 'device.desired_state.accepted')
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_outbox_identity_not_blank CHECK (
|
||||||
|
btrim(tenant_id) <> '' AND btrim(site_id) <> ''
|
||||||
|
AND btrim(device_id) <> '' AND btrim(actor_id) <> ''
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_outbox_actor_type CHECK (
|
||||||
|
actor_type IN ('user', 'service', 'system')
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_outbox_generation_positive CHECK (aggregate_generation >= 1),
|
||||||
|
CONSTRAINT sense_outbox_quota_version_positive CHECK (
|
||||||
|
quota_source_version IS NULL OR quota_source_version >= 1
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_outbox_area_version_positive CHECK (
|
||||||
|
area_policy_source_version IS NULL OR area_policy_source_version >= 1
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_outbox_attempt_nonnegative CHECK (attempt_count >= 0),
|
||||||
|
CONSTRAINT sense_outbox_reason_length CHECK (reason IS NULL OR char_length(reason) <= 500),
|
||||||
|
CONSTRAINT sense_outbox_trace_length CHECK (trace_id IS NULL OR char_length(trace_id) <= 128),
|
||||||
|
CONSTRAINT sense_outbox_payload_object CHECK (jsonb_typeof(payload) = 'object'),
|
||||||
|
CONSTRAINT sense_outbox_payload_kind CHECK (
|
||||||
|
(event_type = 'device.created' AND payload ->> 'kind' = 'device_created')
|
||||||
|
OR (event_type = 'device.desired_state.accepted'
|
||||||
|
AND payload ->> 'kind' = 'desired_state_accepted')
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_outbox_payload_redacted CHECK (
|
||||||
|
NOT (payload ?| ARRAY[
|
||||||
|
'endpoint_ref', 'credential_ref', 'profile_token', 'path_name',
|
||||||
|
'password', 'stream_uri', 'mediamtx_config'
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.device_operation_outbox OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sense_outbox_delivery_idx
|
||||||
|
ON sense.device_operation_outbox(delivered_at, next_attempt_at, available_at, event_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS sense_outbox_device_idx
|
||||||
|
ON sense.device_operation_outbox(tenant_id, site_id, device_id, occurred_at, event_id);
|
||||||
|
|
||||||
|
INSERT INTO sense.schema_migrations(version) VALUES (3)
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Extend the T-009 least-privilege boundary to the Area projection and new
|
||||||
|
-- Sense-owned state. Reapplying this file is intentional and safe.
|
||||||
|
|
||||||
|
REVOKE ALL ON TABLE bell.area_policy_v1 FROM PUBLIC;
|
||||||
|
REVOKE ALL ON TABLE bell.areas FROM PUBLIC;
|
||||||
|
REVOKE ALL ON FUNCTION bell.bump_area_version() FROM PUBLIC;
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA bell TO sense_app;
|
||||||
|
GRANT SELECT ON TABLE bell.area_policy_v1 TO sense_app;
|
||||||
|
REVOKE ALL ON TABLE bell.areas FROM sense_app;
|
||||||
|
REVOKE ALL ON FUNCTION bell.bump_area_version() FROM sense_app;
|
||||||
|
|
||||||
|
REVOKE ALL ON TABLE sense.area_policy_projection_state FROM PUBLIC;
|
||||||
|
REVOKE ALL ON TABLE sense.device_operation_outbox FROM PUBLIC;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
|
||||||
|
sense.area_policy_projection_state,
|
||||||
|
sense.device_operation_outbox
|
||||||
|
TO sense_app;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
-- Sense Control API v1 durable concurrency, idempotency and batch-operation state.
|
||||||
|
|
||||||
|
ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS profile_token text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS resource_version bigint NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
DO $area_preflight$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM sense.devices WHERE area_id IS NULL OR btrim(area_id) = '') THEN
|
||||||
|
RAISE EXCEPTION 'assign every legacy Sense device to a valid Area before installing Control API v4';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$area_preflight$;
|
||||||
|
ALTER TABLE sense.devices ALTER COLUMN area_id SET NOT NULL;
|
||||||
|
|
||||||
|
DO $constraints$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'sense.devices'::regclass
|
||||||
|
AND conname = 'sense_devices_resource_version_positive'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sense.devices ADD CONSTRAINT sense_devices_resource_version_positive
|
||||||
|
CHECK (resource_version >= 1);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$constraints$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.control_idempotency_receipts (
|
||||||
|
scope_hash bytea PRIMARY KEY,
|
||||||
|
request_hash bytea NOT NULL,
|
||||||
|
operation_name text NOT NULL,
|
||||||
|
principal_id text NOT NULL,
|
||||||
|
tenant_id text NOT NULL,
|
||||||
|
site_id text NOT NULL,
|
||||||
|
response_status integer NOT NULL,
|
||||||
|
response_body jsonb NOT NULL,
|
||||||
|
response_etag text,
|
||||||
|
response_location text,
|
||||||
|
trace_id text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT sense_control_receipt_hash_lengths CHECK (
|
||||||
|
octet_length(scope_hash) = 32 AND octet_length(request_hash) = 32
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_control_receipt_identity_not_blank CHECK (
|
||||||
|
btrim(operation_name) <> '' AND btrim(principal_id) <> ''
|
||||||
|
AND btrim(tenant_id) <> '' AND btrim(site_id) <> '' AND btrim(trace_id) <> ''
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_control_receipt_status CHECK (response_status BETWEEN 200 AND 299),
|
||||||
|
CONSTRAINT sense_control_receipt_operation CHECK (
|
||||||
|
operation_name IN ('createDevice', 'batchSetDeviceDesiredState')
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_control_receipt_lengths CHECK (
|
||||||
|
char_length(principal_id) <= 200 AND char_length(trace_id) <= 128
|
||||||
|
AND (response_etag IS NULL OR char_length(response_etag) <= 128)
|
||||||
|
AND (response_location IS NULL OR char_length(response_location) <= 512)
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_control_receipt_body_object CHECK (jsonb_typeof(response_body) = 'object'),
|
||||||
|
CONSTRAINT sense_control_receipt_ttl CHECK (expires_at >= created_at + interval '24 hours')
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.control_idempotency_receipts OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sense_control_receipts_expiry_idx
|
||||||
|
ON sense.control_idempotency_receipts(expires_at, scope_hash);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.batch_operations (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
tenant_id text NOT NULL,
|
||||||
|
site_id text NOT NULL,
|
||||||
|
principal_id text NOT NULL,
|
||||||
|
status text NOT NULL,
|
||||||
|
trace_id text NOT NULL,
|
||||||
|
submitted_at timestamptz NOT NULL,
|
||||||
|
completed_at timestamptz,
|
||||||
|
CONSTRAINT sense_batch_operation_id_format CHECK (
|
||||||
|
id ~ '^op_[0-9A-HJKMNP-TV-Z]{26}$'
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_batch_operation_identity_not_blank CHECK (
|
||||||
|
btrim(tenant_id) <> '' AND btrim(site_id) <> ''
|
||||||
|
AND btrim(principal_id) <> '' AND btrim(trace_id) <> ''
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_batch_operation_status CHECK (
|
||||||
|
status IN ('queued', 'running', 'succeeded', 'partially_succeeded', 'failed')
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_batch_operation_completion CHECK (
|
||||||
|
(status IN ('queued', 'running') AND completed_at IS NULL)
|
||||||
|
OR (status IN ('succeeded', 'partially_succeeded', 'failed') AND completed_at IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.batch_operations OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.batch_operation_items (
|
||||||
|
operation_id text NOT NULL REFERENCES sense.batch_operations(id) ON DELETE CASCADE,
|
||||||
|
ordinal integer NOT NULL,
|
||||||
|
device_id text NOT NULL,
|
||||||
|
status text NOT NULL,
|
||||||
|
error_code text,
|
||||||
|
message text,
|
||||||
|
generation bigint,
|
||||||
|
PRIMARY KEY (operation_id, ordinal),
|
||||||
|
CONSTRAINT sense_batch_item_ordinal CHECK (ordinal BETWEEN 0 AND 127),
|
||||||
|
CONSTRAINT sense_batch_item_device_not_blank CHECK (btrim(device_id) <> ''),
|
||||||
|
CONSTRAINT sense_batch_item_status CHECK (
|
||||||
|
status IN ('accepted', 'rejected', 'succeeded', 'failed')
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_batch_item_error_pair CHECK (
|
||||||
|
(status IN ('accepted', 'succeeded') AND error_code IS NULL AND message IS NULL)
|
||||||
|
OR (status IN ('rejected', 'failed') AND error_code IS NOT NULL AND message IS NOT NULL)
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_batch_item_error_code CHECK (
|
||||||
|
error_code IS NULL OR error_code IN (
|
||||||
|
'invalid_request', 'unauthenticated', 'forbidden', 'not_found',
|
||||||
|
'conflict', 'precondition_required', 'etag_mismatch',
|
||||||
|
'idempotency_conflict', 'duplicate_serial_number', 'quota_exceeded',
|
||||||
|
'quota_projection_unavailable', 'quota_projection_invalid',
|
||||||
|
'area_policy_denied', 'area_policy_unavailable', 'adapter_not_ready',
|
||||||
|
'authentication_failed', 'endpoint_credentials_forbidden',
|
||||||
|
'batch_too_large', 'service_unavailable', 'internal_error'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_batch_item_message_length CHECK (
|
||||||
|
message IS NULL OR char_length(message) <= 500
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_batch_item_generation CHECK (generation IS NULL OR generation >= 1)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.batch_operation_items OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sense_batch_operations_scope_idx
|
||||||
|
ON sense.batch_operations(tenant_id, site_id, submitted_at DESC, id);
|
||||||
|
|
||||||
|
ALTER TABLE sense.device_operation_outbox DROP CONSTRAINT IF EXISTS sense_outbox_event_type;
|
||||||
|
ALTER TABLE sense.device_operation_outbox ADD CONSTRAINT sense_outbox_event_type CHECK (
|
||||||
|
event_type IN (
|
||||||
|
'device.created',
|
||||||
|
'device.desired_state.accepted',
|
||||||
|
'device.configuration.accepted'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.device_operation_outbox DROP CONSTRAINT IF EXISTS sense_outbox_payload_kind;
|
||||||
|
ALTER TABLE sense.device_operation_outbox ADD CONSTRAINT sense_outbox_payload_kind CHECK (
|
||||||
|
(event_type = 'device.created' AND payload ->> 'kind' = 'device_created')
|
||||||
|
OR (event_type = 'device.desired_state.accepted'
|
||||||
|
AND payload ->> 'kind' = 'desired_state_accepted')
|
||||||
|
OR (event_type = 'device.configuration.accepted'
|
||||||
|
AND payload ->> 'kind' = 'configuration_accepted')
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO sense.schema_migrations(version) VALUES (4)
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Extend the least-privilege boundary to Control API durable state.
|
||||||
|
|
||||||
|
REVOKE ALL ON TABLE sense.control_idempotency_receipts FROM PUBLIC;
|
||||||
|
REVOKE ALL ON TABLE sense.batch_operations FROM PUBLIC;
|
||||||
|
REVOKE ALL ON TABLE sense.batch_operation_items FROM PUBLIC;
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
|
||||||
|
sense.control_idempotency_receipts,
|
||||||
|
sense.batch_operations,
|
||||||
|
sense.batch_operation_items
|
||||||
|
TO sense_app;
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
-- Add fenced reconciliation claims and durable, secret-free MediaMTX path
|
||||||
|
-- ownership/orphan reports. Runtime migrations are installed out of process.
|
||||||
|
|
||||||
|
ALTER TABLE sense.reconcile_state
|
||||||
|
ADD COLUMN IF NOT EXISTS lease_owner text,
|
||||||
|
ADD COLUMN IF NOT EXISTS lease_token text,
|
||||||
|
ADD COLUMN IF NOT EXISTS lease_until timestamptz;
|
||||||
|
|
||||||
|
DO $constraints$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'sense.reconcile_state'::regclass
|
||||||
|
AND conname = 'sense_reconcile_lease_all_or_none'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sense.reconcile_state
|
||||||
|
ADD CONSTRAINT sense_reconcile_lease_all_or_none CHECK (
|
||||||
|
(lease_owner IS NULL AND lease_token IS NULL AND lease_until IS NULL)
|
||||||
|
OR (btrim(lease_owner) <> '' AND btrim(lease_token) <> '' AND lease_until IS NOT NULL)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$constraints$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sense_reconcile_lease_due_idx
|
||||||
|
ON sense.reconcile_state(lease_until, next_attempt_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.media_path_ownership (
|
||||||
|
path_name text PRIMARY KEY,
|
||||||
|
device_id text NOT NULL,
|
||||||
|
tenant_id text NOT NULL,
|
||||||
|
site_id text NOT NULL,
|
||||||
|
first_claimed_at timestamptz NOT NULL,
|
||||||
|
last_confirmed_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT sense_media_path_ownership_not_blank CHECK (
|
||||||
|
btrim(path_name) <> '' AND btrim(device_id) <> ''
|
||||||
|
AND btrim(tenant_id) <> '' AND btrim(site_id) <> ''
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.media_path_ownership OWNER TO sense_app;
|
||||||
|
|
||||||
|
-- Keep the historical row independent from device lifecycle so a later device
|
||||||
|
-- deletion cannot erase the evidence needed to classify an owned stale path.
|
||||||
|
INSERT INTO sense.media_path_ownership(
|
||||||
|
path_name, device_id, tenant_id, site_id, first_claimed_at, last_confirmed_at
|
||||||
|
)
|
||||||
|
SELECT d.path_name, d.id, d.tenant_id, d.site_id, d.created_at, d.updated_at
|
||||||
|
FROM sense.devices d
|
||||||
|
WHERE btrim(d.path_name) <> ''
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM sense.device_capabilities c
|
||||||
|
WHERE c.device_id = d.id AND c.capability = 'video_capture'
|
||||||
|
)
|
||||||
|
ON CONFLICT (path_name) DO UPDATE SET
|
||||||
|
device_id = EXCLUDED.device_id,
|
||||||
|
tenant_id = EXCLUDED.tenant_id,
|
||||||
|
site_id = EXCLUDED.site_id,
|
||||||
|
last_confirmed_at = GREATEST(
|
||||||
|
sense.media_path_ownership.last_confirmed_at,
|
||||||
|
EXCLUDED.last_confirmed_at
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.operational_leases (
|
||||||
|
lease_name text PRIMARY KEY,
|
||||||
|
owner_id text NOT NULL,
|
||||||
|
fencing_token text NOT NULL,
|
||||||
|
lease_until timestamptz NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT sense_operational_lease_not_blank CHECK (
|
||||||
|
btrim(lease_name) <> '' AND btrim(owner_id) <> '' AND btrim(fencing_token) <> ''
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.operational_leases OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.orphan_scan_runs (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
instance_id text NOT NULL,
|
||||||
|
observed_count integer NOT NULL,
|
||||||
|
owned_stale_count integer NOT NULL,
|
||||||
|
unowned_count integer NOT NULL,
|
||||||
|
safety_allowed boolean NOT NULL,
|
||||||
|
safety_reason text NOT NULL,
|
||||||
|
completed_at timestamptz NOT NULL,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT sense_orphan_scan_id CHECK (id ~ '^scan_[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
CONSTRAINT sense_orphan_scan_counts CHECK (
|
||||||
|
observed_count >= 0 AND owned_stale_count >= 0 AND unowned_count >= 0
|
||||||
|
AND owned_stale_count + unowned_count <= observed_count
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_orphan_scan_not_blank CHECK (
|
||||||
|
btrim(instance_id) <> '' AND btrim(safety_reason) <> ''
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_orphan_scan_expiry CHECK (expires_at > completed_at)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.orphan_scan_runs OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.orphan_scan_findings (
|
||||||
|
scan_id text NOT NULL REFERENCES sense.orphan_scan_runs(id) ON DELETE CASCADE,
|
||||||
|
path_name text NOT NULL,
|
||||||
|
classification text NOT NULL,
|
||||||
|
device_id text,
|
||||||
|
PRIMARY KEY (scan_id, path_name),
|
||||||
|
UNIQUE (scan_id, path_name, classification),
|
||||||
|
CONSTRAINT sense_orphan_finding_classification CHECK (
|
||||||
|
classification IN ('owned_stale', 'unowned')
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_orphan_finding_not_blank CHECK (
|
||||||
|
btrim(path_name) <> '' AND (device_id IS NULL OR btrim(device_id) <> '')
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_orphan_finding_owner_shape CHECK (
|
||||||
|
(classification = 'owned_stale' AND device_id IS NOT NULL)
|
||||||
|
OR (classification = 'unowned' AND device_id IS NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.orphan_scan_findings OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sense.orphan_cleanup_actions (
|
||||||
|
scan_id text NOT NULL REFERENCES sense.orphan_scan_runs(id),
|
||||||
|
path_name text NOT NULL,
|
||||||
|
classification text NOT NULL DEFAULT 'owned_stale',
|
||||||
|
actor_id text NOT NULL,
|
||||||
|
status text NOT NULL,
|
||||||
|
error_code text,
|
||||||
|
attempted_at timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (scan_id, path_name),
|
||||||
|
FOREIGN KEY (scan_id, path_name, classification)
|
||||||
|
REFERENCES sense.orphan_scan_findings(scan_id, path_name, classification),
|
||||||
|
CONSTRAINT sense_orphan_cleanup_owned_only CHECK (classification = 'owned_stale'),
|
||||||
|
CONSTRAINT sense_orphan_cleanup_status CHECK (status IN ('deleted', 'failed')),
|
||||||
|
CONSTRAINT sense_orphan_cleanup_not_blank CHECK (
|
||||||
|
btrim(path_name) <> '' AND btrim(actor_id) <> ''
|
||||||
|
AND (error_code IS NULL OR btrim(error_code) <> '')
|
||||||
|
),
|
||||||
|
CONSTRAINT sense_orphan_cleanup_error_shape CHECK (
|
||||||
|
(status = 'deleted' AND error_code IS NULL)
|
||||||
|
OR (status = 'failed' AND error_code IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE sense.orphan_cleanup_actions OWNER TO sense_app;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sense_orphan_scan_completed_idx
|
||||||
|
ON sense.orphan_scan_runs(completed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS sense_orphan_cleanup_status_idx
|
||||||
|
ON sense.orphan_cleanup_actions(scan_id, status);
|
||||||
|
|
||||||
|
INSERT INTO sense.schema_migrations(version) VALUES (5)
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Extend the least-privilege boundary to reconciliation and orphan state.
|
||||||
|
|
||||||
|
REVOKE ALL ON TABLE
|
||||||
|
sense.media_path_ownership,
|
||||||
|
sense.operational_leases,
|
||||||
|
sense.orphan_scan_runs,
|
||||||
|
sense.orphan_scan_findings,
|
||||||
|
sense.orphan_cleanup_actions
|
||||||
|
FROM PUBLIC;
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
|
||||||
|
sense.media_path_ownership,
|
||||||
|
sense.operational_leases,
|
||||||
|
sense.orphan_scan_runs,
|
||||||
|
sense.orphan_scan_findings,
|
||||||
|
sense.orphan_cleanup_actions
|
||||||
|
TO sense_app;
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
-- Bell v3 immutable event facts and append-only outcomes.
|
||||||
|
-- bell_app remains the migration/object owner; bell_runtime is a NOLOGIN
|
||||||
|
-- least-privilege group for the Bell process.
|
||||||
|
|
||||||
|
DO $role$
|
||||||
|
DECLARE
|
||||||
|
role_record pg_roles%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO role_record FROM pg_roles WHERE rolname = 'bell_runtime';
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
CREATE ROLE bell_runtime NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;
|
||||||
|
ELSIF role_record.rolcanlogin OR role_record.rolsuper OR role_record.rolcreatedb
|
||||||
|
OR role_record.rolcreaterole OR role_record.rolreplication THEN
|
||||||
|
RAISE EXCEPTION 'existing bell_runtime role has unsafe attributes';
|
||||||
|
END IF;
|
||||||
|
IF pg_has_role('bell_runtime', 'bell_app', 'MEMBER')
|
||||||
|
OR pg_has_role('bell_runtime', 'sense_app', 'MEMBER') THEN
|
||||||
|
RAISE EXCEPTION 'bell_runtime must not inherit migration or Sense roles';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$role$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION bell.reject_immutable_change()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = pg_catalog, bell
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'Bell immutable facts cannot be updated or deleted';
|
||||||
|
END
|
||||||
|
$function$;
|
||||||
|
ALTER FUNCTION bell.reject_immutable_change() OWNER TO bell_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bell.events (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
tenant_id bigint NOT NULL,
|
||||||
|
site_id bigint NOT NULL,
|
||||||
|
device_id bigint NOT NULL,
|
||||||
|
source_event_id text NOT NULL,
|
||||||
|
kind text NOT NULL,
|
||||||
|
severity text NOT NULL,
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
detected_at timestamptz NOT NULL,
|
||||||
|
payload_hash bytea NOT NULL,
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
CONSTRAINT bell_event_id_format CHECK (id ~ '^evt_[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
CONSTRAINT bell_event_ids_positive CHECK (tenant_id >= 1 AND site_id >= 1 AND device_id >= 1),
|
||||||
|
CONSTRAINT bell_event_source_id CHECK (source_event_id ~ '^[A-Za-z0-9_-]{1,128}$'),
|
||||||
|
CONSTRAINT bell_event_kind CHECK (kind ~ '^[a-z][a-z0-9_]{2,63}$'),
|
||||||
|
CONSTRAINT bell_event_severity CHECK (severity IN ('low', 'medium', 'high', 'critical')),
|
||||||
|
CONSTRAINT bell_event_time_order CHECK (detected_at >= occurred_at),
|
||||||
|
CONSTRAINT bell_event_hash_length CHECK (octet_length(payload_hash) = 32),
|
||||||
|
CONSTRAINT bell_event_payload_object CHECK (jsonb_typeof(payload) = 'object'),
|
||||||
|
CONSTRAINT bell_event_payload_identity CHECK (
|
||||||
|
payload ->> 'id' = id
|
||||||
|
AND (payload ->> 'tenant_id')::bigint = tenant_id
|
||||||
|
AND (payload ->> 'site_id')::bigint = site_id
|
||||||
|
AND (payload ->> 'device_id')::bigint = device_id
|
||||||
|
AND payload ->> 'source_event_id' = source_event_id
|
||||||
|
AND payload ->> 'kind' = kind
|
||||||
|
AND payload ->> 'severity' = severity
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE bell.events OWNER TO bell_app;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS bell_events_immutable ON bell.events;
|
||||||
|
CREATE TRIGGER bell_events_immutable
|
||||||
|
BEFORE UPDATE OR DELETE ON bell.events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change();
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS bell_events_scope_time_idx
|
||||||
|
ON bell.events(tenant_id, site_id, occurred_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS bell_events_device_time_idx
|
||||||
|
ON bell.events(tenant_id, site_id, device_id, occurred_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS bell_events_kind_time_idx
|
||||||
|
ON bell.events(tenant_id, site_id, kind, occurred_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS bell_events_source_lookup_idx
|
||||||
|
ON bell.events(tenant_id, site_id, device_id, source_event_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bell.event_outcomes (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
event_id text NOT NULL REFERENCES bell.events(id),
|
||||||
|
outcome text NOT NULL,
|
||||||
|
outcome_source text NOT NULL,
|
||||||
|
reason text,
|
||||||
|
actor_type text NOT NULL,
|
||||||
|
actor_id text NOT NULL,
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
record_hash bytea NOT NULL,
|
||||||
|
received_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
CONSTRAINT bell_event_outcome_id_format CHECK (id ~ '^out_[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||||
|
CONSTRAINT bell_event_outcome_value CHECK (
|
||||||
|
outcome IN ('unknown', 'true_positive', 'false_positive', 'subject_recovered', 'duplicate', 'test')
|
||||||
|
),
|
||||||
|
CONSTRAINT bell_event_outcome_source CHECK (outcome_source IN ('auto', 'manual')),
|
||||||
|
CONSTRAINT bell_event_outcome_actor CHECK (
|
||||||
|
actor_type IN ('user', 'service', 'system') AND btrim(actor_id) <> ''
|
||||||
|
),
|
||||||
|
CONSTRAINT bell_event_outcome_reason CHECK (reason IS NULL OR char_length(reason) <= 500),
|
||||||
|
CONSTRAINT bell_event_outcome_hash_length CHECK (octet_length(record_hash) = 32)
|
||||||
|
);
|
||||||
|
ALTER TABLE bell.event_outcomes OWNER TO bell_app;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS bell_event_outcomes_immutable ON bell.event_outcomes;
|
||||||
|
CREATE TRIGGER bell_event_outcomes_immutable
|
||||||
|
BEFORE UPDATE OR DELETE ON bell.event_outcomes
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change();
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS bell_event_outcomes_event_time_idx
|
||||||
|
ON bell.event_outcomes(event_id, occurred_at, id);
|
||||||
|
|
||||||
|
INSERT INTO bell.schema_migrations(version) VALUES (3)
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Runtime Bell can append/read event facts but cannot rewrite, delete or
|
||||||
|
-- truncate them. Object ownership stays with the offline migration role.
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION bell.reject_immutable_change() FROM PUBLIC;
|
||||||
|
REVOKE ALL ON TABLE bell.events, bell.event_outcomes FROM PUBLIC;
|
||||||
|
REVOKE ALL ON TABLE bell.events, bell.event_outcomes FROM bell_runtime;
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA bell TO bell_runtime;
|
||||||
|
GRANT SELECT ON TABLE bell.schema_migrations TO bell_runtime;
|
||||||
|
GRANT SELECT, INSERT ON TABLE bell.events, bell.event_outcomes TO bell_runtime;
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE bell_app IN SCHEMA bell
|
||||||
|
REVOKE ALL ON TABLES FROM PUBLIC;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE bell_app IN SCHEMA bell
|
||||||
|
REVOKE ALL ON FUNCTIONS FROM PUBLIC;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# YoVision PostgreSQL 初始化
|
||||||
|
|
||||||
|
本目录实现 T-009~T-012、T-015 的 PostgreSQL `17.10` schema。SQL 必须按文件名前缀顺序执行:`001`~`004` 创建 NOLOGIN 权限角色、Bell/Sense 初始对象和配额权限;`005`~`007` 增量增加 Area 与审计;`008`~`009` 增加 Control API 状态;`010`~`011` 增加调和 fencing、MediaMTX Path 历史归属、孤儿报告/受控处置结果;`012`~`013` 增加 Bell 不可变事件、append-only outcome 和独立 `bell_runtime` 最小权限。全部 SQL 可重放。对象 owner/迁移角色为 `bell_app`/`sense_app`;应用登录角色及密码由部署环境或密钥系统创建,Sense 登录加入 `sense_app`,Bell 运行登录只加入 `bell_runtime`,仓库不保存登录凭据。
|
||||||
|
|
||||||
|
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
|
||||||
|
|
||||||
|
安装 `008` 前必须先查询并为全部历史 `sense.devices.area_id IS NULL/blank` 记录完成经批准的 Area 归属;migration 会失败关闭而不会猜测或自动搬迁这些设备。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:YOVISION_PG_ADMIN_DSN = '由管理员私下设置'
|
||||||
|
Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql |
|
||||||
|
Sort-Object Name |
|
||||||
|
ForEach-Object { & 'D:\pgsql17\bin\psql.exe' -X -v ON_ERROR_STOP=1 -d $env:YOVISION_PG_ADMIN_DSN -f $_.FullName }
|
||||||
|
```
|
||||||
|
|
||||||
|
不要把该示例替换成真实连接串后提交。自动验收使用 [`scripts/test_postgres.ps1`](../../scripts/test_postgres.ps1) 启动隔离临时集群,不读取或修改 `D:\pgsql17\data`。
|
||||||
|
|
||||||
|
## 权限边界
|
||||||
|
|
||||||
|
- `bell_app` 拥有 `bell.sites`/`bell.areas`、版本 trigger、`bell.site_quota_v1` 和 `bell.area_policy_v1`。
|
||||||
|
- `sense_app` 拥有 `sense` schema,只获得 `bell` schema 的 `USAGE` 和两个投影视图的 `SELECT`。
|
||||||
|
- `sense_app` 对 Bell 源表、Bell migration 表和 trigger function 没有权限;启动检查发现权限过宽时拒绝运行。
|
||||||
|
- `sense.device_operation_outbox` 是本地持久化审计事实,不是 Bell 全局审计真相;relay 的传输、签名、确认和留存尚未实现。
|
||||||
|
- `bell.events` 与 `bell.event_outcomes` 由 `bell_app` 拥有;`bell_runtime` 只获得 `SELECT/INSERT`,没有 owner、`UPDATE`、`DELETE` 或 `TRUNCATE` 权限,数据库 trigger 再拒绝 owner 路径的意外事实改写。
|
||||||
|
- `sense.control_idempotency_receipts` 不保存原始 Idempotency-Key,只保存 scope/request SHA-256 和脱敏响应快照;`batch_operations`/items 只保存逻辑 ID、状态和稳定错误,不保存连接秘密。
|
||||||
|
- 调和与孤儿租约使用 PostgreSQL `clock_timestamp()` 和 fencing token;过期 worker 不能提交完成/失败或扫描报告。`media_path_ownership`、扫描和处置表不保存 endpoint、credential 或 source URI;数据库约束禁止为 `unowned` finding 写删除结果。
|
||||||
|
- PUBLIC 对两个业务 schema 的表和函数没有权限。
|
||||||
|
|
||||||
|
## 恢复
|
||||||
|
|
||||||
|
T-009~T-012 不迁移或删除现有 SQLite 数据,默认运行配置也仍为 SQLite。SQLite 不具备生产 Area/Outbox/Control API、多实例租约或孤儿处置一致性,业务路由不会在 SQLite 上注册。若 PostgreSQL 切换验证失败,先关闭 Control API 和孤儿扫描;需要实验室回退时停止 PostgreSQL 模式进程并恢复 SQLite DSN,不得误报为生产语义等价。schema 不提供自动 down migration;需要移除时由管理员先备份,再只删除明确确认的 YoVision 专用数据库,禁止对共享实例做模糊或递归清理。
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
DO $assertions$
|
||||||
|
DECLARE
|
||||||
|
actual_columns text[];
|
||||||
|
default_quota integer;
|
||||||
|
initial_version bigint;
|
||||||
|
updated_version bigint;
|
||||||
|
area_initial_version bigint;
|
||||||
|
area_updated_version bigint;
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('server_version_num')::integer < 170000
|
||||||
|
OR current_setting('server_version_num')::integer >= 180000 THEN
|
||||||
|
RAISE EXCEPTION 'YoVision requires PostgreSQL 17.x';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT array_agg(column_name ORDER BY ordinal_position)
|
||||||
|
INTO actual_columns
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'bell' AND table_name = 'site_quota_v1';
|
||||||
|
IF actual_columns IS DISTINCT FROM ARRAY[
|
||||||
|
'tenant_id', 'site_id', 'max_video_channels', 'source_version', 'source_updated_at'
|
||||||
|
] THEN
|
||||||
|
RAISE EXCEPTION 'site_quota_v1 signature drift: %', actual_columns;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT has_table_privilege('sense_app', 'bell.site_quota_v1', 'SELECT') THEN
|
||||||
|
RAISE EXCEPTION 'sense_app lacks SELECT on quota view';
|
||||||
|
END IF;
|
||||||
|
IF has_table_privilege('sense_app', 'bell.site_quota_v1', 'INSERT')
|
||||||
|
OR has_table_privilege('sense_app', 'bell.site_quota_v1', 'UPDATE')
|
||||||
|
OR has_table_privilege('sense_app', 'bell.site_quota_v1', 'DELETE') THEN
|
||||||
|
RAISE EXCEPTION 'sense_app has forbidden quota-view write privileges';
|
||||||
|
END IF;
|
||||||
|
IF has_table_privilege('sense_app', 'bell.sites', 'SELECT')
|
||||||
|
OR has_table_privilege('sense_app', 'bell.sites', 'INSERT')
|
||||||
|
OR has_table_privilege('sense_app', 'bell.sites', 'UPDATE')
|
||||||
|
OR has_table_privilege('sense_app', 'bell.sites', 'DELETE') THEN
|
||||||
|
RAISE EXCEPTION 'sense_app has forbidden Bell source-table privileges';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT array_agg(column_name ORDER BY ordinal_position)
|
||||||
|
INTO actual_columns
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'bell' AND table_name = 'area_policy_v1';
|
||||||
|
IF actual_columns IS DISTINCT FROM ARRAY[
|
||||||
|
'tenant_id', 'site_id', 'area_id', 'capture_policy',
|
||||||
|
'source_version', 'source_updated_at'
|
||||||
|
] THEN
|
||||||
|
RAISE EXCEPTION 'area_policy_v1 signature drift: %', actual_columns;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT has_table_privilege('sense_app', 'bell.area_policy_v1', 'SELECT') THEN
|
||||||
|
RAISE EXCEPTION 'sense_app lacks SELECT on Area policy view';
|
||||||
|
END IF;
|
||||||
|
IF has_table_privilege('sense_app', 'bell.area_policy_v1', 'INSERT')
|
||||||
|
OR has_table_privilege('sense_app', 'bell.area_policy_v1', 'UPDATE')
|
||||||
|
OR has_table_privilege('sense_app', 'bell.area_policy_v1', 'DELETE')
|
||||||
|
OR has_table_privilege('sense_app', 'bell.areas', 'SELECT')
|
||||||
|
OR has_table_privilege('sense_app', 'bell.areas', 'INSERT')
|
||||||
|
OR has_table_privilege('sense_app', 'bell.areas', 'UPDATE')
|
||||||
|
OR has_table_privilege('sense_app', 'bell.areas', 'DELETE') THEN
|
||||||
|
RAISE EXCEPTION 'sense_app violates the Area projection boundary';
|
||||||
|
END IF;
|
||||||
|
IF has_function_privilege('sense_app', 'bell.bump_area_version()', 'EXECUTE') THEN
|
||||||
|
RAISE EXCEPTION 'sense_app can execute the Bell Area version trigger function';
|
||||||
|
END IF;
|
||||||
|
IF NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'SELECT')
|
||||||
|
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'INSERT')
|
||||||
|
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'UPDATE')
|
||||||
|
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'DELETE') THEN
|
||||||
|
RAISE EXCEPTION 'sense_app lacks access to its local audit Outbox';
|
||||||
|
END IF;
|
||||||
|
IF (SELECT max(version) FROM bell.schema_migrations) <> 3
|
||||||
|
OR (SELECT max(version) FROM sense.schema_migrations) <> 5 THEN
|
||||||
|
RAISE EXCEPTION 'schema migration version drift';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_roles
|
||||||
|
WHERE rolname = 'bell_runtime' AND NOT rolcanlogin AND NOT rolsuper
|
||||||
|
AND NOT rolcreatedb AND NOT rolcreaterole AND NOT rolreplication
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'bell_runtime role is missing or unsafe';
|
||||||
|
END IF;
|
||||||
|
IF NOT has_table_privilege('yovision_t015_bell', 'bell.events', 'SELECT,INSERT')
|
||||||
|
OR NOT has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'SELECT,INSERT')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.events', 'UPDATE')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.events', 'DELETE')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.events', 'TRUNCATE')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'UPDATE')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'DELETE')
|
||||||
|
OR has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'TRUNCATE') THEN
|
||||||
|
RAISE EXCEPTION 'Bell runtime violates immutable event privileges';
|
||||||
|
END IF;
|
||||||
|
IF has_table_privilege('public', 'bell.events', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE')
|
||||||
|
OR has_table_privilege('public', 'bell.event_outcomes', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') THEN
|
||||||
|
RAISE EXCEPTION 'Bell event facts leaked to PUBLIC';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO bell.sites(tenant_id, id, name)
|
||||||
|
VALUES ('assertion-tenant', 'assertion-site', 'Assertion Site')
|
||||||
|
RETURNING max_video_channels, version INTO default_quota, initial_version;
|
||||||
|
IF default_quota <> 16 OR initial_version <> 1 THEN
|
||||||
|
RAISE EXCEPTION 'Bell site default quota/version drift';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE bell.sites SET name = 'Assertion Site Updated'
|
||||||
|
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-site'
|
||||||
|
RETURNING version INTO updated_version;
|
||||||
|
IF updated_version <> initial_version + 1 THEN
|
||||||
|
RAISE EXCEPTION 'Bell site source_version is not monotonic';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO bell.areas(tenant_id, site_id, id, name, capture_policy)
|
||||||
|
VALUES (
|
||||||
|
'assertion-tenant', 'assertion-site', 'assertion-area',
|
||||||
|
'Assertion Area', 'video_allowed'
|
||||||
|
)
|
||||||
|
RETURNING version INTO area_initial_version;
|
||||||
|
|
||||||
|
UPDATE bell.areas SET capture_policy = 'non_imaging_only'
|
||||||
|
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-area'
|
||||||
|
RETURNING version INTO area_updated_version;
|
||||||
|
IF area_initial_version <> 1 OR area_updated_version <> area_initial_version + 1 THEN
|
||||||
|
RAISE EXCEPTION 'Bell Area source_version is not monotonic';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO bell.areas(tenant_id, site_id, id, name, capture_policy)
|
||||||
|
VALUES ('assertion-tenant', 'assertion-site', 'invalid-area', 'Invalid', 'unknown');
|
||||||
|
RAISE EXCEPTION 'unknown capture_policy unexpectedly accepted';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN check_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO bell.sites(tenant_id, id, name, max_video_channels)
|
||||||
|
VALUES ('assertion-tenant', 'invalid-site', 'Invalid', 129);
|
||||||
|
RAISE EXCEPTION 'quota 129 unexpectedly accepted';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN check_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
DELETE FROM bell.areas
|
||||||
|
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-area';
|
||||||
|
DELETE FROM bell.sites
|
||||||
|
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-site';
|
||||||
|
END
|
||||||
|
$assertions$;
|
||||||
|
|
||||||
|
DO $control_api$
|
||||||
|
BEGIN
|
||||||
|
IF NOT has_table_privilege('yovision_t012_sense', 'sense.control_idempotency_receipts', 'SELECT,INSERT,UPDATE,DELETE')
|
||||||
|
OR NOT has_table_privilege('yovision_t012_sense', 'sense.batch_operations', 'SELECT,INSERT,UPDATE,DELETE')
|
||||||
|
OR NOT has_table_privilege('yovision_t012_sense', 'sense.batch_operation_items', 'SELECT,INSERT,UPDATE,DELETE') THEN
|
||||||
|
RAISE EXCEPTION 'Sense runtime lacks Control API table privileges';
|
||||||
|
END IF;
|
||||||
|
IF has_table_privilege('public', 'sense.control_idempotency_receipts', 'SELECT')
|
||||||
|
OR has_table_privilege('public', 'sense.batch_operations', 'SELECT') THEN
|
||||||
|
RAISE EXCEPTION 'Control API durable state leaked to PUBLIC';
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'sense' AND table_name = 'devices'
|
||||||
|
AND column_name = 'resource_version'
|
||||||
|
) OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'sense' AND table_name = 'devices'
|
||||||
|
AND column_name = 'profile_token'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Control API device columns are missing';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$control_api$;
|
||||||
|
|
||||||
|
DO $reconcile_safety$
|
||||||
|
DECLARE
|
||||||
|
target text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH target IN ARRAY ARRAY[
|
||||||
|
'sense.media_path_ownership',
|
||||||
|
'sense.operational_leases',
|
||||||
|
'sense.orphan_scan_runs',
|
||||||
|
'sense.orphan_scan_findings',
|
||||||
|
'sense.orphan_cleanup_actions'
|
||||||
|
] LOOP
|
||||||
|
IF NOT has_table_privilege('yovision_t012_sense', target, 'SELECT,INSERT,UPDATE,DELETE') THEN
|
||||||
|
RAISE EXCEPTION 'Sense runtime lacks reconciliation safety privilege on %', target;
|
||||||
|
END IF;
|
||||||
|
IF has_table_privilege('public', target, 'SELECT,INSERT,UPDATE,DELETE') THEN
|
||||||
|
RAISE EXCEPTION 'Reconciliation safety state leaked to PUBLIC on %', target;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'sense' AND table_name = 'reconcile_state'
|
||||||
|
AND column_name = 'lease_token'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'reconcile fencing columns are missing';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$reconcile_safety$;
|
||||||
@@ -40,19 +40,19 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
|
|||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
当前为 **M0:兼容性验证 + 需求定稿**。
|
当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储基础**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置,不阻塞 Sense Outbox → Bell 审计 relay。
|
||||||
|
|
||||||
优先路径:
|
优先路径:
|
||||||
|
|
||||||
1. M0:3–5 款摄像头跑通 ONVIF 核心操作,形成采购白名单;关闭架构影响型开放问题。
|
1. M0:已用一台真实样机完成首期指定“型号 + 硬件版本 + 固件”ONVIF/RTSP 准入;结论不外推为多品牌兼容,真实断网恢复证据按负责人豁免留痕。
|
||||||
2. M1:只在 `Sense/` 建立 MediaMTX 生产接入骨架,5 路自动建 path、探活、断线重建。
|
2. M1:只在 `Sense/` 建立 MediaMTX 生产接入骨架,以 1 路准入实机 + 至少 4 路独立合成源完成五路自动建 path、探活和断线重建。
|
||||||
3. M2:对账、多租户、隧道和至少一个站点的 16 路全流程。
|
3. M2:Control API、多租户投影、调和 fencing、孤儿安全闸和本地 16 路批量收敛/30 分钟稳定基线已完成;WireGuard 等客户网络条件具备后补验。
|
||||||
4. M3:Brain + Bell 起步,默认 16 路端到端事件、预警、ack 与误报反馈。
|
4. M3:Brain + Bell 起步,默认 16 路端到端事件、预警、ack 与误报反馈。
|
||||||
5. M4–M5:64/128 路分片、管理端和第二/第三场景包。
|
5. M4–M5:64/128 路分片、管理端和第二/第三场景包。
|
||||||
|
|
||||||
## 任务领取与状态
|
## 任务领取与状态
|
||||||
|
|
||||||
- Gitea Issue 是实时状态权威,状态标签为 `status/todo`、`status/doing`、`status/blocked`、`status/review`、`status/done`。
|
- Gitea Issue 是实时状态权威,状态标签为 `status/waiting`、`status/todo`、`status/doing`、`status/blocked`、`status/review`、`status/done`;`waiting` 表示尚未领取且因依赖或外部条件不可领取。
|
||||||
- 任务文件保存不可变规格、依赖、写路径、验证门禁和执行证据。
|
- 任务文件保存不可变规格、依赖、写路径、验证门禁和执行证据。
|
||||||
- 一个 agent 同时最多一个活跃任务;一个任务同时只有一个写入者。
|
- 一个 agent 同时最多一个活跃任务;一个任务同时只有一个写入者。
|
||||||
- dispatcher 创建 `claims/T-<编号>` 和 `agent/<agent-id>/T-<编号>` 后,worker 必须读回确认。
|
- dispatcher 创建 `claims/T-<编号>` 和 `agent/<agent-id>/T-<编号>` 后,worker 必须读回确认。
|
||||||
@@ -85,6 +85,15 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
|
|||||||
python scripts/validate_agent_context.py
|
python scripts/validate_agent_context.py
|
||||||
python -m unittest discover -s tests -p "test_*.py"
|
python -m unittest discover -s tests -p "test_*.py"
|
||||||
python scripts/validate_harness_governance.py
|
python scripts/validate_harness_governance.py
|
||||||
|
go -C Sense generate ./internal/mtx ./internal/controlapi
|
||||||
|
go -C Sense test ./...
|
||||||
|
go -C Sense vet ./...
|
||||||
|
go -C Sense build ./...
|
||||||
|
go -C Bell test ./...
|
||||||
|
go -C Bell vet ./...
|
||||||
|
go -C Bell build ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
当前没有生产代码构建命令。代码出现后,以 [`03-tech-stack.md`](03-tech-stack.md) 的验证矩阵和当前任务门禁为准。
|
日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;默认只监听回环地址,具体配置、MediaMTX 版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md) 和 [`../Sense/README.md`](../Sense/README.md)。
|
||||||
|
|
||||||
|
本机 16 路软件容量基线使用 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17`;正式证据必须使用默认 30 分钟窗口,且只证明固定低码率合成负载。结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
|
||||||
|
|||||||
+14
-2
@@ -18,20 +18,25 @@
|
|||||||
## 2. 当前 M0 验收
|
## 2. 当前 M0 验收
|
||||||
|
|
||||||
- 在隔离实验室运行 MiBeeNvr 等现成测试台,不改 `_reference/`,不接真实客户摄像头。
|
- 在隔离实验室运行 MiBeeNvr 等现成测试台,不改 `_reference/`,不接真实客户摄像头。
|
||||||
- 选择 3–5 款候选摄像头,逐款通过 `GetProfiles`、`GetStreamUri`、`SetSystemDateAndTime`。
|
- 首期统一采购一个指定摄像头型号;至少使用一台真实样机,对冻结的“厂商 + 型号 + 硬件版本 + 固件版本”组合通过 `GetProfiles`、`GetStreamUri`、`SetSystemDateAndTime`。
|
||||||
- 记录断线恢复、认证失败、时间漂移、主/子码流和厂商差异,形成采购白名单。
|
- 记录认证失败、时间漂移、主/子码流各至少 10 分钟以及至少 3 次断线恢复,形成指定型号准入记录。结论不得外推到其他型号/固件、生产批次或多品牌兼容;基线变化必须重新验证。
|
||||||
- 执行 `raw/01-需求收集.md` §8 已批准的决策台账;只将明确记录的下游法务、客户、硬件和供应商门禁留到对应里程碑。
|
- 执行 `raw/01-需求收集.md` §8 已批准的决策台账;只将明确记录的下游法务、客户、硬件和供应商门禁留到对应里程碑。
|
||||||
- M0 代码与临时配置可丢弃,不作为生产基线;可借鉴范围严格遵循 NVR 白名单。
|
- M0 代码与临时配置可丢弃,不作为生产基线;可借鉴范围严格遵循 NVR 白名单。
|
||||||
|
|
||||||
|
本地硬件开发基线为现有一台 Hikvision IP Camera,精确型号/硬件/固件由 T-001 冻结;生产代码仍只依赖标准 ONVIF/RTSP adapter,不得写死海康品牌或私有地址。M1 实验室使用该 1 路实机和至少 4 条可独立启停的合成 RTSP 上游完成五路软件闭环。合成源可用于功能、配额和容量测试,但不能证明多台真实设备故障隔离、批次一致性或生产 SLA;这些结论由客户授权、借用或租赁设备的 T-007 现场门禁提供。
|
||||||
|
|
||||||
## 3. P0 功能要求
|
## 3. P0 功能要求
|
||||||
|
|
||||||
### 3.1 接入与设备
|
### 3.1 接入与设备
|
||||||
|
|
||||||
- 支持标准 ONVIF/RTSP,不绑定摄像头品牌。
|
- 支持标准 ONVIF/RTSP,不绑定摄像头品牌。
|
||||||
|
- 台账与管理端以“设备”为根实体,使用 `modality` 表达 video / radar / contact / button / wearable / other,并使用 `capabilities` 决定是否展示画面、媒体、空间配置、遥测等能力;M1~M5 只完整实现 video 适配器,M6 再接入非视频设备,但不得因此把数据模型和一级信息架构写死为摄像头。
|
||||||
- 支持 NAT 后的边缘主动推流;设备身份使用稳定序列号而非 IP。
|
- 支持 NAT 后的边缘主动推流;设备身份使用稳定序列号而非 IP。
|
||||||
- 批量开通不依赖逐路手工操作,支持待激活中间态。
|
- 批量开通不依赖逐路手工操作,支持待激活中间态。
|
||||||
- 探活、离线告警、开通校时、断线自动恢复。
|
- 探活、离线告警、开通校时、断线自动恢复。
|
||||||
- 容量写入时校验站点配额;配额服务不可用时拒绝新增/启用,但不影响已有流。
|
- 容量写入时校验站点配额;配额服务不可用时拒绝新增/启用,但不影响已有流。
|
||||||
|
- Site、Area 与隐私准入策略由 Bell 统一持有;`capture_policy = video_allowed | non_imaging_only` 通过版本化内部 API 或只读投影提供给 Sense。Sense 在设备新增/启用时依据设备成像能力校验,并显示策略版本/同步状态;策略读取失败时拒绝新的成像设备变更并告警,已有链路不静默停用或伪装为已收敛。
|
||||||
|
- ONVIF 能力探测必须持久化厂商、型号、固件、认证方式、Profiles、校时与事件订阅结果以及探测时间;UI 区分探测值和最终生效值。M6 前未实现的非视频协议适配器使用 `adapter_not_ready`,不得伪造在线状态或遥测。
|
||||||
|
|
||||||
### 3.2 分析与规则
|
### 3.2 分析与规则
|
||||||
|
|
||||||
@@ -51,8 +56,13 @@
|
|||||||
|
|
||||||
- 预警必须有 ack;未 ack 自动升级,进程重启后能续跑。
|
- 预警必须有 ack;未 ack 自动升级,进程重启后能续跑。
|
||||||
- 升级链、超时、联系人和时段可按租户/站点配置。
|
- 升级链、超时、联系人和时段可按租户/站点配置。
|
||||||
|
- 联系人与值班排班必须统一设计并共享人员、值班组和已验证通知通道主数据,但分对象、分版本管理:联系人不承载轮换字段,排班不复制手机号;升级步骤通过类型化目标引用指定人员、值班组或排班计划,不写死号码。
|
||||||
|
- 值班排班至少覆盖站点时区、周轮换、生效日期、临时替班、空档/重叠冲突检查、当前与未来值班人预览、版本发布和审计。每次投递创建时解析当时生效的排班版本,并固化实际收件人、通道与解析版本快照;后续修改不得改写历史投递事实。
|
||||||
- 至少两条独立投递路径,其中一条可绕过互联网。
|
- 至少两条独立投递路径,其中一条可绕过互联网。
|
||||||
- 区分已发出、已送达、已看到;没有回执不能当成功。
|
- 区分已发出、已送达、已看到;没有回执不能当成功。
|
||||||
|
- Alert 与 Event 保持可导航的多对多关系;被规则抑制且没有创建 Alert 的 Event 不伪装为已投递。
|
||||||
|
- 班次交接覆盖未 ack、处置中与升级中的 Alert;接班确认留痕,交接过程不暂停或重置升级链。
|
||||||
|
- 交接班只显式转移进行中 Alert 的处置责任,不静默修改未来排班;未来班次替换通过排班临时替班并发布新版本完成。
|
||||||
- 静默必须限时且自动恢复,单次不超过 4 小时,无永久静默。
|
- 静默必须限时且自动恢复,单次不超过 4 小时,无永久静默。
|
||||||
- 业务预警与运维告警使用不同通道和值班配置。
|
- 业务预警与运维告警使用不同通道和值班配置。
|
||||||
|
|
||||||
@@ -61,12 +71,14 @@
|
|||||||
- 多租户数据、账号、配置和存储隔离;最小 RBAC 为平台管理员、租户管理员、站点管理员、值班员、只读。
|
- 多租户数据、账号、配置和存储隔离;最小 RBAC 为平台管理员、租户管理员、站点管理员、值班员、只读。
|
||||||
- 全链路审计,预警生命周期可追溯。
|
- 全链路审计,预警生命周期可追溯。
|
||||||
- Prometheus/Grafana 至少覆盖设备在线、流状态、推理延迟、事件量、未收敛项和投递 SLA。
|
- Prometheus/Grafana 至少覆盖设备在线、流状态、推理延迟、事件量、未收敛项和投递 SLA。
|
||||||
|
- Sense 提供面向接入运维的运维中心,聚合对账差异、重试退避、孤儿安全闸、媒体/推理分片、边缘隧道/补传和运维告警;它不承担 Bell 的业务预警和全局管理审计。
|
||||||
- 断网时边缘缓存事件,恢复后补传。
|
- 断网时边缘缓存事件,恢复后补传。
|
||||||
- 不在代码、日志、证据文件名和工单中泄露摄像头凭据、客户名或敏感地址。
|
- 不在代码、日志、证据文件名和工单中泄露摄像头凭据、客户名或敏感地址。
|
||||||
|
|
||||||
### 3.6 管理端与外部集成
|
### 3.6 管理端与外部集成
|
||||||
|
|
||||||
- Bell 自研并持有事件、Alert、ack、升级链、租户和审计真相;客户既有平台不得成为这些状态的唯一真相源。
|
- Bell 自研并持有事件、Alert、ack、升级链、租户和审计真相;客户既有平台不得成为这些状态的唯一真相源。
|
||||||
|
- Bell 管理端持有 Tenant、Site、Area、RBAC、配额和全局审计真相;Sense 只消费当前租户/站点/角色上下文和带版本的配额/Area 策略投影。Area 策略变更与已有成像设备冲突时必须显式迁移或取消,禁止静默停用。
|
||||||
- 对外使用版本化 OpenAPI/Webhook;M3 客户端为值班室 Web + 响应式移动 H5,可嵌入客户系统。
|
- 对外使用版本化 OpenAPI/Webhook;M3 客户端为值班室 Web + 响应式移动 H5,可嵌入客户系统。
|
||||||
- 投递层必须使用供应商无关的 provider 接口;试点至少有本地声光/Web 与一条短信或语音,生产前补齐两条独立路径及故障切换。
|
- 投递层必须使用供应商无关的 provider 接口;试点至少有本地声光/Web 与一条短信或语音,生产前补齐两条独立路径及故障切换。
|
||||||
|
|
||||||
|
|||||||
+65
-5
@@ -8,6 +8,7 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 首期部署 | 客户侧私有化实例 | 保留 `tenant_id`、RBAC、schema 和 API 的 SaaS-ready 隔离边界 |
|
| 首期部署 | 客户侧私有化实例 | 保留 `tenant_id`、RBAC、schema 和 API 的 SaaS-ready 隔离边界 |
|
||||||
| M1–M3 视频接入 | ONVIF / RTSP | 售前盘点存量 NVR;GB/T 28181-2022 不进当前 MVP,按需另建适配任务 |
|
| M1–M3 视频接入 | ONVIF / RTSP | 售前盘点存量 NVR;GB/T 28181-2022 不进当前 MVP,按需另建适配任务 |
|
||||||
|
| 本地摄像头基线 | 1 台 Hikvision IP Camera + 独立合成 RTSP 源 | 海康精确型号/固件由 T-001 冻结;T-006 冻结合成 publisher 及版本。仅为开发策略,不在业务代码写死品牌,也不替代 T-007 真实多路现场验收 |
|
||||||
| Sense | Go | 设备、ONVIF、MediaMTX 控制与对账 |
|
| Sense | Go | 设备、ONVIF、MediaMTX 控制与对账 |
|
||||||
| 媒体数据面 | MediaMTX 独立二进制 | MIT;M1 正式生产基线 |
|
| 媒体数据面 | MediaMTX 独立二进制 | MIT;M1 正式生产基线 |
|
||||||
| MediaMTX API | 从官方 OpenAPI 用 `oapi-codegen` 生成 + 薄封装 | 不依赖第三方非官方 SDK |
|
| MediaMTX API | 从官方 OpenAPI 用 `oapi-codegen` 生成 + 薄封装 | 不依赖第三方非官方 SDK |
|
||||||
@@ -28,6 +29,44 @@
|
|||||||
| 指标 | Prometheus + Grafana | 三系统统一可观测入口 |
|
| 指标 | Prometheus + Grafana | 三系统统一可观测入口 |
|
||||||
| 追踪 | OpenTelemetry + Jaeger | 端到端事件链路 |
|
| 追踪 | OpenTelemetry + Jaeger | 端到端事件链路 |
|
||||||
|
|
||||||
|
### 1.1 Sense M1 冻结版本(T-003)
|
||||||
|
|
||||||
|
| 组件 | 冻结版本 | 许可证 / 校验 | 升级与退出路线 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Go | `1.26.5`(`go 1.26.0` + `toolchain go1.26.5`) | BSD-3-Clause;从 `go.dev/dl` 校验,Windows amd64 ZIP SHA-256 `97e6b2a833b6d89f9ff17d25419ac0a7e3b482a044e9ab18cdef834bd834fd38` | 跟随仍受支持的 Go 小版本,先在 CI/目标平台跑全量测试再升级;标准 Go module,无私有运行时绑定 |
|
||||||
|
| SQLite driver | `modernc.org/sqlite v1.54.0` | BSD-3-Clause;module sum `h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=`,go.mod sum `h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=` | 选择无 CGO driver 以简化 Windows/边缘部署;只经 `database/sql` 与 repository 使用,可替换 driver;生产仍迁移到 PostgreSQL `sense` schema |
|
||||||
|
| MediaMTX | `v1.19.3` | MIT;官方 release `checksums.sha256`:Windows amd64 `5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe`、Linux amd64 `a7ba21268fccda3ebc43fdad76b87fddb85ce77e725b5cb637bca724b5394fbe`、Linux arm64 `9e5b38a5b5fcab1916341b024031b2fc5dc6a2059baed9ba3f3b0d3768d231a8` | 独立进程,不链接到 Sense;升级时先更新 vendored OpenAPI、重新生成并跑假服务契约测试;可通过 `mtx` port 更换媒体数据面 |
|
||||||
|
| oapi-codegen | `v2.8.0` | Apache-2.0;module sum `h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM=`,go.mod sum `h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo=` | 仅为构建工具;版本锁在 module tool dependency,生成文件与薄封装分离;升级后必须重新生成并检查 diff |
|
||||||
|
| oapi-codegen runtime | `v1.6.0` | Apache-2.0;module sum `h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=`,go.mod sum `h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=` | 生成客户端的最小运行时;与生成器一起升级并跑假 HTTP 契约测试,退出时随生成客户端一并替换 |
|
||||||
|
|
||||||
|
MediaMTX 官方 `v1.19.3` OpenAPI 固定保存于 `Sense/api/vendor/mediamtx-v1.19.3.openapi.yaml`,SHA-256 为 `a2b58195f1ec76541e124b5de4ee54645e5a3e25f70c4a73acc4a44d6f2b9c52`。下载二进制后必须对照上表或官方同版 `checksums.sha256`,不得使用浮动 `latest` URL。SQLite `v1.56.0` 在本决策日刚发布,T-003 不追新;后续依赖升级单独评审。
|
||||||
|
|
||||||
|
### 1.2 Sense M2 PostgreSQL 冻结版本(T-009)
|
||||||
|
|
||||||
|
| 组件 | 冻结版本 | 许可证 / 校验 | 使用与退出路线 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| PostgreSQL | `17.10` | PostgreSQL License;本机验收二进制 `D:\pgsql17\bin\postgres.exe` 与 `psql.exe` 均报告 `17.10` | 首期一个实例、`sense`/`bell` schema 分离;自动测试用同版二进制启动隔离临时集群,不改现有服务。升级 17.x 小版本须重跑 migration、权限和 repository 集成测试;跨大版本先做备份恢复演练 |
|
||||||
|
| Go PostgreSQL driver | `github.com/jackc/pgx/v5 v5.10.0` 的 `stdlib` | MIT;module sum `h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=`,go.mod sum `h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=` | 只通过 `database/sql` 和 store port 使用;DSN 只来自进程环境且不记录。退出时可更换 `database/sql` driver,业务域与调和器不依赖 pgx 类型 |
|
||||||
|
|
||||||
|
M2 仍保留 SQLite 作为单机开发与 T-006 回归入口,但生产真相源切换目标是 PostgreSQL。运行时必须显式设置 `SENSE_DB_DRIVER=postgres` 和私有 `SENSE_DB_DSN` 才使用 PostgreSQL;仓库不提供带密码 DSN。初始 schema 由 `deploy/postgres/` 的顺序 migration 创建,不在 Sense 进程启动时使用高权限自动迁移。
|
||||||
|
|
||||||
|
T-011 复用同一套冻结的 Go、PostgreSQL、pgx 与 `oapi-codegen`,未增加第三方依赖。Sense Control API v1 从仓库 OpenAPI 生成 `net/http` server glue;首版认证是可替换 port 下的外部静态 SHA-256 注册表,公共路由默认关闭且只在 PostgreSQL 生产路径启用。T-011 首次落成 v4 schema,T-012 后进程统一要求 v5。静态适配器是私有部署启动方案,不替代后续 Bell/JWT/OIDC 身份服务。
|
||||||
|
|
||||||
|
T-012 同样未增加第三方依赖:PostgreSQL v5 使用数据库时钟租约和 fencing token 协调多实例调和/孤儿扫描;MediaMTX Path 枚举继续使用同版生成客户端。`/metrics` 由 Go 标准库输出 Prometheus 文本格式,只包含固定结果枚举、构建/实例信息和汇总 gauge/counter,不使用 tenant、Site、device 或 Path 标签。孤儿处置是 PostgreSQL 专用本地命令,默认周期任务只报告、不自动删除。
|
||||||
|
|
||||||
|
T-014 没有增加生产依赖。Windows 容量脚本冻结并核对 Sense 模块 Go 1.26.5、PostgreSQL 17.10、MediaMTX v1.19.3 与 FFmpeg 8.1.2;MediaMTX Windows amd64 ZIP 使用上表官方 SHA-256,解压后 `mediamtx.exe` 另固定为 `1cda85249312cb9463f9f94c5a712b9f160c9af3fd9490f0d4723911d7880e05`。FFmpeg 只生成无人物夹具并以 16 个独立 `-c copy` 进程发布,不进入生产镜像或 Go module。正式本机结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
|
||||||
|
|
||||||
|
### 1.3 Bell M3 事件存储冻结版本(T-015)
|
||||||
|
|
||||||
|
| 组件 | 冻结版本 | 许可证 / 校验 | 使用与退出路线 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Go | `1.26.5`(与 Sense 相同) | BSD-3-Clause;复用 §1.1 工具链与校验 | Bell 独立 Go module;升级时同时运行两个 module 的 test/vet/build |
|
||||||
|
| PostgreSQL / pgx | `17.10` / `github.com/jackc/pgx/v5 v5.10.0` | PostgreSQL License / MIT;module sum `h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=` | 复用 T-009 的 `database/sql` 边界;运行身份改用无 owner 权限的 `bell_runtime` 组 |
|
||||||
|
| JSON Schema | `github.com/santhosh-tekuri/jsonschema/v6 v6.0.2` | Apache-2.0;module sum `h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=` | 运行时校验 Draft 2020-12 与 format;退出时可替换 validator,但冻结 schema 和负向契约测试不变 |
|
||||||
|
| ULID | `github.com/oklog/ulid/v2 v2.1.2` | Apache-2.0;module sum `h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=` | 只封装在 Bell ID generator port 后;平台前缀仍为 `evt_`,替换实现不得改变格式或所有权 |
|
||||||
|
|
||||||
|
T-015 不冻结 Brain→Bell transport,也不产生可部署 Bell API 二进制。内部 factory 接收不含 `id` 的候选事实,由 Bell 生成 ULID 后才形成最终 v0.1 事件;不得把该 Go 类型当成公共网络协议。
|
||||||
|
|
||||||
## 2. 外部项目边界
|
## 2. 外部项目边界
|
||||||
|
|
||||||
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
|
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
|
||||||
@@ -37,7 +76,7 @@
|
|||||||
|
|
||||||
## 3. 待冻结项
|
## 3. 待冻结项
|
||||||
|
|
||||||
- Go、Python、PostgreSQL、MediaMTX、Savant/DeepStream 的精确版本。
|
- Python、Savant/DeepStream 的精确版本;Go、MediaMTX 与 PostgreSQL 已分别为 Sense M1/M2 冻结,后续阶段可按升级流程调整。
|
||||||
- Bell 前端框架和组件库。
|
- Bell 前端框架和组件库。
|
||||||
- 事件投递 transport 从 HTTP 起步还是直接采用消息总线。
|
- 事件投递 transport 从 HTTP 起步还是直接采用消息总线。
|
||||||
- 目标 GPU/边缘硬件、解码能力和每 worker 的 `max_sources`。
|
- 目标 GPU/边缘硬件、解码能力和每 worker 的 `max_sources`。
|
||||||
@@ -48,7 +87,7 @@
|
|||||||
|
|
||||||
## 4. 当前标准入口
|
## 4. 当前标准入口
|
||||||
|
|
||||||
仓库当前只有文档和契约,未产生可构建生产代码。根目录脚本执行文档治理验证:
|
Sense M1 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
./init.ps1
|
./init.ps1
|
||||||
@@ -60,6 +99,26 @@ WSL/Linux/macOS/Git Bash:
|
|||||||
./init.sh
|
./init.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Sense 单独执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go -C Sense mod download
|
||||||
|
go -C Sense generate ./internal/mtx ./internal/controlapi
|
||||||
|
go -C Sense test ./...
|
||||||
|
go -C Sense vet ./...
|
||||||
|
go -C Sense build ./...
|
||||||
|
go -C Sense run ./cmd/sense-api
|
||||||
|
```
|
||||||
|
|
||||||
|
Bell 事件域基础单独执行(当前没有可启动 API):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go -C Bell mod download
|
||||||
|
go -C Bell test ./...
|
||||||
|
go -C Bell vet ./...
|
||||||
|
go -C Bell build ./...
|
||||||
|
```
|
||||||
|
|
||||||
直接验证:
|
直接验证:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -74,9 +133,10 @@ python scripts/validate_harness_governance.py
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Harness 文档/任务/Gitea 模板 | 上述三条 Python 命令 | 任一治理协议、清单或任务 schema 变化 | 不适用 |
|
| Harness 文档/任务/Gitea 模板 | 上述三条 Python 命令 | 任一治理协议、清单或任务 schema 变化 | 不适用 |
|
||||||
| `docs/raw/contracts/` | JSON Schema 校验 + 契约代码断言(实现后补命令) | schema/示例/mapper 任一变化 | 生产者与消费者联合评审 |
|
| `docs/raw/contracts/` | JSON Schema 校验 + 契约代码断言(实现后补命令) | schema/示例/mapper 任一变化 | 生产者与消费者联合评审 |
|
||||||
| Sense Go | `go test ./...`、`go vet ./...`(代码出现后) | ONVIF、存储、MediaMTX、对账或公共 API 变化 | 命中设备任务时使用指定摄像头矩阵 |
|
| Sense Go | `go -C Sense generate ./internal/mtx ./internal/controlapi`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 |
|
||||||
|
| PostgreSQL schema/repository | `python -m unittest discover -s tests -p "test_postgres_contract.py"`;Windows 本机再运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` | migration、权限、配额判定或 PostgreSQL driver 变化 | 不需要摄像头;必须核对临时集群未使用现有 data 目录,现有 5432 listener 前后不变 |
|
||||||
| Brain Python | 单元测试、类型/格式检查(命令待项目脚手架冻结) | mapper、判定状态机、模型接口变化 | 命中模型任务时用冻结数据集和目标硬件 |
|
| Brain Python | 单元测试、类型/格式检查(命令待项目脚手架冻结) | mapper、判定状态机、模型接口变化 | 命中模型任务时用冻结数据集和目标硬件 |
|
||||||
| Bell Go/Web | 后端测试 + 前端 lint/test/build(命令待脚手架冻结) | schema、RBAC、预警状态机或公共 UI 变化 | P0 流程由产品/值班角色验收 |
|
| Bell Go/Web | 当前后端:`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`;前端命令待脚手架冻结 | schema、RBAC、预警状态机或公共 UI 变化 | P0 UI 流程由产品/值班角色验收;纯事件存储不需要 UI 人工验收 |
|
||||||
| 容量/分片 | 任务内基准脚本 | 16/64/128 路里程碑 | 目标网络、媒体和 GPU 硬件必需 |
|
| 容量/分片 | 任务内基准脚本;本地 16 路入口为 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17` | 默认 16 路软件基线,以及后续 64/128 路分片里程碑 | 本地 16 路控制面可用独立合成源;真实多路、生产 SLA、64/128 路、AI/GPU、网络与存储必须使用目标环境分别验收 |
|
||||||
|
|
||||||
代码脚手架落地时必须把真实命令同步到本文、`init.ps1`/`init.sh`、`00-ai-start-here.md` 和 `current-state.md`。
|
代码脚手架落地时必须把真实命令同步到本文、`init.ps1`/`init.sh`、`00-ai-start-here.md` 和 `current-state.md`。
|
||||||
|
|||||||
+33
-19
@@ -16,16 +16,16 @@ YoVision 使用“通用底座 + 场景包”,按变化频率分为:
|
|||||||
|
|
||||||
| 系统 | 语言/状态 | 职责 | 不负责 |
|
| 系统 | 语言/状态 | 职责 | 不负责 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Sense | Go,有状态 | 设备台账、ONVIF、MediaMTX 控制、对账、探活、隧道、设备型触发、流分片 | AI 判定、事件业务、预警 |
|
| Sense | Go,有状态 | 设备台账(`modality + capabilities`)、ONVIF 能力探测、MediaMTX 控制、对账、探活、隧道、设备型触发、流分片、配额/Area 策略投影与准入执行、接入运维中心 | AI 判定、事件业务、预警、Tenant/Site/Area/RBAC/全局审计真相 |
|
||||||
| Brain | Python/CUDA,业务无状态 | 解码/推理、检测/姿态/跟踪/ReID、时间窗判定、事件 mapper、像素级触发 | 设备真相源、告警升级、租户权限 |
|
| Brain | Python/CUDA,业务无状态 | 解码/推理、检测/姿态/跟踪/ReID、时间窗判定、事件 mapper、像素级触发 | 设备真相源、告警升级、租户权限 |
|
||||||
| Bell | Go + Web,有状态 | 事件校验/存储、规则、预警状态机、投递、反馈、租户/RBAC、审计、配额真相源和管理端 | 媒体转发、模型执行 |
|
| Bell | Go + Web,有状态 | 事件校验/存储、规则、预警状态机、投递、反馈、Tenant/Site/Area/RBAC、全局审计、配额与 `capture_policy` 真相源和统一管理端 | 媒体转发、模型执行、设备实际态 |
|
||||||
|
|
||||||
MediaMTX、PostgreSQL、MinIO、Prometheus 等作为独立基础设施部署。
|
MediaMTX、PostgreSQL、MinIO、Prometheus 等作为独立基础设施部署。
|
||||||
|
|
||||||
## 3. 部署与系统边界
|
## 3. 部署与系统边界
|
||||||
|
|
||||||
- 首期每个客户部署一套私有实例,数据和事件证据留在客户环境;数据库实体、RBAC、配置与 API 从第一版携带 `tenant_id` 并保持 SaaS-ready 边界。
|
- 首期每个客户部署一套私有实例,数据和事件证据留在客户环境;数据库实体、RBAC、配置与 API 从第一版携带 `tenant_id` 并保持 SaaS-ready 边界。
|
||||||
- Bell 自研且是事件、Alert、ack、升级链、租户和审计的唯一业务真相源;客户平台通过版本化 OpenAPI/Webhook 集成,不反向接管核心状态机。
|
- Bell 自研且是事件、Alert、ack、升级链、Tenant/Site/Area/RBAC、配额和全局审计的唯一业务真相源;客户平台通过版本化 OpenAPI/Webhook 集成,不反向接管核心状态机。Sense 只保存执行所需的版本化只读投影,不形成第二份组织/策略真相。
|
||||||
- M1–M3 的视频入口只有 ONVIF/RTSP。现有 NVR 在售前盘点;仅支持 GB/T 28181 的项目必须建立独立适配器任务,不把国标信令混入 Sense 最小骨架。
|
- M1–M3 的视频入口只有 ONVIF/RTSP。现有 NVR 在售前盘点;仅支持 GB/T 28181 的项目必须建立独立适配器任务,不把国标信令混入 Sense 最小骨架。
|
||||||
- M3 客户端为值班室 Web + 响应式移动 H5,可嵌入客户系统;是否开发原生 App 在 M4 后另行决定。
|
- M3 客户端为值班室 Web + 响应式移动 H5,可嵌入客户系统;是否开发原生 App 在 M4 后另行决定。
|
||||||
|
|
||||||
@@ -45,17 +45,17 @@ Sense ── 视频流/触发信号 ──> Brain
|
|||||||
|
|
||||||
主流程:
|
主流程:
|
||||||
|
|
||||||
1. Bell 持有站点配额;Sense 在新增/启用设备时通过版本化内部 API 或只读投影校验。
|
1. Bell 持有站点、Area、配额与 `capture_policy`;首期在同一 PostgreSQL 实例内发布 `bell.site_quota_v1` 和 `bell.area_policy_v1` 两个版本化只读视图。T-009/T-010 已实现 Bell 源表/视图、最小权限和 Sense PostgreSQL repository;Sense 按 Area→Site 的固定 advisory-lock 顺序执行策略与配额准入并记录所用版本。未来分库必须发布新版本契约,不能静默改变 v1 语义。
|
||||||
2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态。
|
2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态;PostgreSQL 多实例以数据库时钟短租约和 fencing token 领取 due row,过期 worker 不得提交结果。
|
||||||
3. Brain 消费视频与触发信号,产生符合 v0.1 的事件。
|
3. Brain 消费视频与触发信号,产生符合 v0.1 的事件。
|
||||||
4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。
|
4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。T-015 已实现内部 candidate→final event factory、append-only PostgreSQL repository 和独立 outcome 事实;Brain→Bell transport、认证和公共 API 仍未冻结。
|
||||||
5. 规则命中后创建独立 Alert,先落库再投递,等待 ack 并按策略升级。
|
5. 规则命中后创建独立 Alert,先落库再投递,等待 ack 并按策略升级。
|
||||||
6. Bell 发起 pre-roll 证据回捞,Sense 提供切片接口。
|
6. Bell 发起 pre-roll 证据回捞,Sense 提供切片接口。
|
||||||
7. 用户标记 outcome,反馈进入 Brain 的数据闭环。
|
7. 用户标记 outcome,反馈进入 Brain 的数据闭环。
|
||||||
|
|
||||||
首个 M3 数据流部署在 S2 民办寄宿学校的 16 路高风险点位,只运行越线、危险区域和聚集等匿名规则,不加载人脸底库。
|
首个 M3 数据流部署在 S2 民办寄宿学校的 16 路高风险点位,只运行越线、危险区域和聚集等匿名规则,不加载人脸底库。
|
||||||
|
|
||||||
## 5. 九条不可越界的决定
|
## 5. 十二条不可越界的决定
|
||||||
|
|
||||||
1. MediaMTX 独立运行,Sense 管配置与生命周期。
|
1. MediaMTX 独立运行,Sense 管配置与生命周期。
|
||||||
2. 设备型触发源归 Sense;需要解码的像素级触发归 Brain。
|
2. 设备型触发源归 Sense;需要解码的像素级触发归 Brain。
|
||||||
@@ -63,9 +63,12 @@ Sense ── 视频流/触发信号 ──> Brain
|
|||||||
4. 平台事件 ULID 由 Bell 生成;Brain 只填 `source_event_id`。
|
4. 平台事件 ULID 由 Bell 生成;Brain 只填 `source_event_id`。
|
||||||
5. 一个 PostgreSQL 实例,`sense`/`bell` schema 分离;Brain 无业务 schema。
|
5. 一个 PostgreSQL 实例,`sense`/`bell` schema 分离;Brain 无业务 schema。
|
||||||
6. 16/128 都不是单机保证;媒体与推理按独立分片横向扩展。
|
6. 16/128 都不是单机保证;媒体与推理按独立分片横向扩展。
|
||||||
7. Bell 拥有 `site.max_video_channels`,Sense 在设备写路径执行;不跨 schema 直接写。
|
7. Bell 拥有 Site/Area、`site.max_video_channels` 与 `capture_policy`,并拥有两个 v1 投影视图;Sense 角色只获得视图 `SELECT`,在设备写路径执行准入,不能写 Bell schema 或读取 Bell 源表。
|
||||||
8. 事件片段写入客户侧 MinIO/S3,常态录像留在客户 NVR;元数据/审计、人脸和训练样本使用独立生命周期。
|
8. 事件片段写入客户侧 MinIO/S3,常态录像留在客户 NVR;元数据/审计、人脸和训练样本使用独立生命周期。
|
||||||
9. 投递状态机只依赖 Bell provider 接口,不直接依赖某家短信或语音 SDK;生产前至少两条独立路径并能故障切换。
|
9. 投递状态机只依赖 Bell provider 接口,不直接依赖某家短信或语音 SDK;生产前至少两条独立路径并能故障切换。
|
||||||
|
10. 设备领域模型使用 `modality + capabilities`,页面不以摄像头作为唯一根实体;未实现协议适配器明确为 `adapter_not_ready`,不得用模拟遥测伪装交付。
|
||||||
|
11. Tenant/Site/Area/RBAC、配额、`capture_policy` 与全局审计属于 Bell;Sense Control API v1 只管理 Device 期望态与收敛查询,Sense 只读消费版本化投影并在设备写路径执行,投影不可用时只阻断相关新变更,不静默切断已有链路。
|
||||||
|
12. Sense 的设备操作审计先写本地持久化 Outbox,再由幂等 relay 异步送入 Bell 全局审计;不得使用“先执行高风险操作、再尽力入队”的顺序。T-010 已冻结脱敏本地事件并实现原子写入;relay 的 transport、签名、确认、重放窗口与留存仍须独立冻结。
|
||||||
|
|
||||||
## 6. 容量架构
|
## 6. 容量架构
|
||||||
|
|
||||||
@@ -76,20 +79,30 @@ Sense ── 视频流/触发信号 ──> Brain
|
|||||||
- 单分片故障不能扩散到其他分片。
|
- 单分片故障不能扩散到其他分片。
|
||||||
- 管理端默认查看 16 路,但按 128 路设计分页、虚拟列表、筛选和批量操作。
|
- 管理端默认查看 16 路,但按 128 路设计分页、虚拟列表、筛选和批量操作。
|
||||||
|
|
||||||
|
T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、单个生产 MediaMTX 和 16 个独立低码率合成 publisher 完成 `16 → 0 → 16` 批量收敛、四路发布故障隔离/恢复和 `1800.1 s / 180` 样本稳定观察,最大/最终 `unconverged=0`。这只证明默认 16 路的本地软件控制面与拉流基线,不改变上述分片架构:`media_shard.max_streams=32` 仍是待 64/128 路目标环境压测的初始建议,不能从 T-014 推导单机、真实摄像头、AI/GPU、存储或生产 SLA。完整证据见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
|
||||||
|
|
||||||
## 7. 一致性与失败处理
|
## 7. 一致性与失败处理
|
||||||
|
|
||||||
- PostgreSQL 是期望态真相源;MediaMTX、推理 worker 和对象存储是可对账的实际态。
|
- PostgreSQL `sense` schema 是生产期望态真相源;SQLite 只保留为 M1 本地开发/回归路径。MediaMTX、推理 worker 和对象存储是可对账的实际态。
|
||||||
- 对账器水平触发、幂等、指数退避、限制并发;部分失败不做跨系统回滚,只持续收敛。
|
- 对账器水平触发、幂等、指数退避、限制并发;PostgreSQL 使用 `FOR UPDATE SKIP LOCKED`、每项续租和 fencing token,SQLite 只保留单进程开发语义。部分失败不做跨系统回滚,只持续收敛。
|
||||||
- 孤儿删除必须有 10% 安全闸和人工可观察指标。
|
- MediaMTX Path 扫描把“Sense 历史拥有但当前失配”和“从未归属 Sense”分开;未知归属永不自动删除。历史拥有项也只允许在 15 分钟二次快照、1~128 项和 `候选 × 100 <= 当前 Path 总数 × 10` 全部通过时由本地运维命令逐项处置,不提供绕过。
|
||||||
- 配额读取失败只阻止新增/启用,不中断已有流。
|
- `bell.site_quota_v1` 行缺失、数值越界、版本回退或读取失败只阻止视频设备新增/启用,不中断已有流;降低配额导致超限时不自动停用,后续准入返回稳定错误并产生运维信号。多 Sense 实例使用 PostgreSQL transaction-scoped advisory lock 串行化同 tenant/site 的计数与写入,不能用进程内锁替代。
|
||||||
|
- `bell.area_policy_v1` 缺失、非法、版本回退或读取失败时,PostgreSQL repository 拒绝相关新增/启用;`non_imaging_only` 允许非成像设备但拒绝具有 `video_capture` 的设备。已有设备保持原状态,策略冲突由 Bell 管理端显式迁移或取消。同库实时视图不以源记录年龄误判 freshness。
|
||||||
|
- 设备创建和期望态受理在本地事务内同时写脱敏 `sense.device_operation_outbox`;Outbox 失败回滚业务写入,相同期望态不增加 generation 但仍审计。异步 relay 尚未实现。
|
||||||
|
- Bell 最终事件写入 `bell.events`;同平台 ID/同摘要仅视为幂等重放,同 ID/不同摘要拒绝。`bell_runtime` 只有 `SELECT/INSERT`,事件与 outcome 的 UPDATE/DELETE 另由数据库 trigger 拒绝;后续人工/自动 outcome 追加到独立表,不改写事件 payload。
|
||||||
- Brain 投递失败落本地队列重试,不阻塞实时推理主链路。
|
- Brain 投递失败落本地队列重试,不阻塞实时推理主链路。
|
||||||
- Alert 先落库再投递,进程重启恢复未完成升级链。
|
- Alert 先落库再投递,进程重启恢复未完成升级链。
|
||||||
|
- 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。
|
||||||
- 事件证据技术默认保留 30 天并按生命周期删除;客户/法务在 M3 生产上线前确认法规适用性和最终期限,技术默认值不能覆盖其结论。
|
- 事件证据技术默认保留 30 天并按生命周期删除;客户/法务在 M3 生产上线前确认法规适用性和最终期限,技术默认值不能覆盖其结论。
|
||||||
|
|
||||||
## 8. 数据与契约
|
## 8. 数据与契约
|
||||||
|
|
||||||
- 核心实体:Tenant → Site → Area/Device → StreamBinding/Zone;Rule → Event → Alert → DeliveryAttempt/Ack。
|
- Bell 核心实体:Tenant → Site → Area(含 `capture_policy`)以及 Role/Binding/Quota/Audit;Sense 核心实体:Device(含 `modality + capabilities`)→ StreamBinding/Zone,以及只记录已观察版本的 SiteQuota/AreaPolicyProjection。两个 schema 以稳定逻辑 ID 关联,不跨 schema 写入;配额 v1 为五列,Area v1 固定为 `tenant_id/site_id/area_id/capture_policy/source_version/source_updated_at` 六列。
|
||||||
- Event 与 Alert 不合并:一个事件可触发多次预警与投递,一次预警也可聚合多个事件。
|
- Sense Control API v1 使用站点作用域路径、认证上下文 tenant、HMAC cursor 分页、PostgreSQL 幂等收据与资源 ETag;敏感连接引用只写不读。T-011 已实现 7 个 handler,并以 feature flag 限定到 PostgreSQL 路径;首版外部静态 SHA-256 注册表只实现认证 port 的私有部署适配器。正式签名和兼容规则以 [`contracts/`](contracts/) 为准,Bell 管理服务、JWT/OIDC 与 Outbox relay 仍未实现。
|
||||||
|
- 业务实体:Rule → Event → Alert → DeliveryAttempt/Ack;Event 与 Alert 不合并。
|
||||||
|
- Bell 通知域分为三个聚合:Contact/Team 保存身份、成员关系和已验证通道;OnCallSchedule/ScheduleVersion/ShiftException 保存时区、轮换与例外;EscalationPolicy/Step 通过 `person / team / on_call_schedule` 类型化 `target_ref` 引用目标。三者共享逻辑 ID,不复制手机号、班次或轮换字段。
|
||||||
|
- 每个 DeliveryAttempt 创建时解析当时生效的排班版本,并保存实际收件人、通道、`schedule_version` 和解析时间快照;之后联系人或排班修改不得回写既有投递事实。
|
||||||
|
- 一个事件可触发多次预警与投递,一次预警也可聚合多个事件。
|
||||||
- 事件 v0.1 以 `raw/contracts/event-v0.1.schema.json` 与 `raw/contracts/README.md` 为准;未知顶层字段拒绝,只允许通过 `ext` 扩展。
|
- 事件 v0.1 以 `raw/contracts/event-v0.1.schema.json` 与 `raw/contracts/README.md` 为准;未知顶层字段拒绝,只允许通过 `ext` 扩展。
|
||||||
- v0.1 还需代码校验时间自洽、`confidence` 当前为 null、证据文件名隐私、唯一 primary sensor 等跨字段约束。
|
- v0.1 还需代码校验时间自洽、`confidence` 当前为 null、证据文件名隐私、唯一 primary sensor 等跨字段约束。
|
||||||
- S2 MVP 不处理人脸。首个人脸试点最早 M5,只允许经法务/客户门禁确认的 S4 成人访客/承包商白名单(≤10,000 人),并提供非人脸替代方式。
|
- S2 MVP 不处理人脸。首个人脸试点最早 M5,只允许经法务/客户门禁确认的 S4 成人访客/承包商白名单(≤10,000 人),并提供非人脸替代方式。
|
||||||
@@ -97,21 +110,22 @@ Sense ── 视频流/触发信号 ──> Brain
|
|||||||
## 9. 目录目标
|
## 9. 目录目标
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Sense/cmd + Sense/internal/{device,onvif,mtx,reconcile,probe,trigger,tunnel,authcb,store}
|
Sense/cmd + Sense/internal/{device,onvif,mtx,reconcile,orphan,metrics,probe,trigger,tunnel,auth,store}
|
||||||
Brain/{pipeline,models,judge,emit,trigger,contracts}
|
Brain/{pipeline,models,judge,emit,trigger,contracts}
|
||||||
Bell/cmd + Bell/internal/{ingest,event,rule,alert,deliver,feedback,tenant,audit,store}
|
Bell/cmd + Bell/internal/{ingest,event,rule,alert,deliver,feedback,tenant,audit,store}
|
||||||
Bell/{web,packs,contracts}
|
Bell/{web,packs,contracts}
|
||||||
|
deploy/postgres/{001_roles.sql,...,013_privileges_bell_events.sql,tests}
|
||||||
```
|
```
|
||||||
|
|
||||||
当前只有空目录占位;真实脚手架必须由对应任务创建。
|
Sense 脚手架和 PostgreSQL `001`~`013` 已实现;Bell 已有事件校验/不可变存储 Go 基础,但没有可部署 API 服务,Brain 仍为目录占位。
|
||||||
|
|
||||||
## 10. 开发顺序
|
## 10. 开发顺序
|
||||||
|
|
||||||
- M0 不写生产代码。
|
- M0 不写生产代码。
|
||||||
- M1 只动 Sense,5 路接入骨架与 MediaMTX。
|
- M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。
|
||||||
- M2 仍以 Sense 为主,完成 16 路开通/停用、对账、多租户投影与隧道。
|
- M2 仍以 Sense 为主;Control API、对账、多租户投影和本地 16 路开通/停用基线已完成,隧道等待客户网络条件后补验。
|
||||||
- M3 Brain 与 Bell 同时起步,事件契约首次被真实使用。
|
- M3 Brain 与 Bell 同时起步,事件契约首次被真实使用。
|
||||||
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包。
|
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。
|
||||||
|
|
||||||
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
|
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
|
||||||
|
|
||||||
|
|||||||
+16
-9
@@ -4,26 +4,31 @@
|
|||||||
|
|
||||||
## M0:兼容性验证与需求定稿
|
## M0:兼容性验证与需求定稿
|
||||||
|
|
||||||
出口:3–5 款摄像头通过 ONVIF 核心操作,采购白名单归档;架构影响型开放问题闭环;M0 临时代码不进入生产基线。
|
出口:至少一台真实样机完成首期指定型号/固件的 ONVIF/RTSP 准入,指定采购基线归档;架构影响型开放问题闭环;M0 临时代码不进入生产基线。该出口不代表多品牌或批次兼容。
|
||||||
|
|
||||||
- T-001:建立摄像头兼容性实验矩阵与采购白名单。
|
- T-001:完成指定摄像头型号准入验证。
|
||||||
- T-002:关闭 Q1/Q2/Q4–Q12 架构影响型需求问题。
|
- T-002:关闭 Q1/Q2/Q4–Q12 架构影响型需求问题。
|
||||||
|
|
||||||
## M1:Sense 接入骨架 + MediaMTX
|
## M1:Sense 接入骨架 + MediaMTX
|
||||||
|
|
||||||
出口:5 路自动建 path、探活、断线重建;使用 MediaMTX 生产数据面,完整 MiBeeNvr 不替代生产基线。
|
出口:1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路自动建 path、探活、断线重建;使用 MediaMTX 生产数据面,完整 MiBeeNvr 不替代生产基线。该出口是实验室软件闭环,不代表真实五机或生产 SLA。
|
||||||
|
|
||||||
- T-003:建立 Sense Go 脚手架、设备台账、ONVIF 与 MediaMTX 薄客户端。
|
- T-003:建立无需真实摄像头即可验证的 Sense Go 脚手架、设备台账、ONVIF port/fake、MediaMTX 生成客户端与薄封装;完成不代表 M0/M1 出口。
|
||||||
- 后续按 T-003 的基线拆分对账、探活、5 路集成与部署任务。
|
- T-003 只冻结 `/healthz`、`/readyz` 运维探针,设备管理公共 API 留给后续契约任务;真实 ONVIF adapter 不用 fake 冒充。
|
||||||
|
- T-006:在 T-001 指定准入基线与 T-003 骨架之上,以 1 路海康实机 + 至少 4 路可独立启停的合成 RTSP 源完成五路自动建 path、探活、恢复和 MediaMTX 实验室集成验收。
|
||||||
|
- T-001 与 T-003 已完成;T-006 可领取实施,只需准备至少 4 条可独立启停的合成源,不要求当前购买更多摄像头。
|
||||||
|
|
||||||
## M2:对账、多租户投影与 16 路全流程
|
## M2:对账、多租户投影与 16 路全流程
|
||||||
|
|
||||||
出口:10 个站点试点,至少一个站点完成 16 路开通/停用;`unconverged = 0` 稳定。
|
出口:10 个站点试点,至少一个站点完成 16 路开通/停用;`unconverged = 0` 稳定。
|
||||||
|
|
||||||
- 对账器幂等/退避/并发/10% 安全闸。
|
- T-008:冻结 Sense Control API v1 与 Bell `site_quota_v1` 只读投影契约;只建立接口和测试门禁,不等于 handler、Bell 表或 migration 已实现。
|
||||||
- Bell 站点配额投影到 Sense 的版本化读取契约。
|
- T-009:使用 PostgreSQL 17.10 建立 `sense`/`bell` schema、Bell 配额源表/只读视图、最小权限和 Sense PostgreSQL repository;SQLite 保留为 M1 本地路径。
|
||||||
- WireGuard 边缘隧道与断网恢复。
|
- T-010:建立 Bell Area/`capture_policy` 只读投影、Sense 成像准入与设备操作审计 Outbox 原子写入;relay 传输协议留给后续任务。
|
||||||
- 16 路批量开通、停用和容量基准。
|
- T-011:按 T-008 契约实现认证 tenant 上下文、7 个设备/operation handler、PostgreSQL 幂等收据、ETag/HMAC cursor、最多 128 项批量操作和停用精确 path 收敛;公共控制 API 默认关闭且只在 PostgreSQL 路径启用。
|
||||||
|
- T-012:以 PostgreSQL 数据库时钟租约和 fencing token 防止多实例重复调和;增加可证明的 Path 历史归属、默认只读孤儿报告、15 分钟二次确认、10% 不可绕过安全闸和低基数 `/metrics`。
|
||||||
|
- T-013(后置,尚未创建):WireGuard 边缘隧道与断网恢复;等待客户网络拓扑、地址规划和部署权限,不阻塞本地软件开发。
|
||||||
|
- T-014:使用隔离 PostgreSQL、真实 Control API 和 16 个独立本地合成 publisher,完成批量开通/停用、局部故障恢复和资源观测;正式基线为 `1800.1 s / 180` 样本、最大/最终 `unconverged=0`,仍只形成实验室软件证据,不替代 T-007/T-013 或生产 SLA。
|
||||||
|
|
||||||
## M3:首个 16 路端到端 MVP
|
## M3:首个 16 路端到端 MVP
|
||||||
|
|
||||||
@@ -31,9 +36,11 @@
|
|||||||
|
|
||||||
- Brain 模型接口、判定内核和 v0.1 mapper。
|
- Brain 模型接口、判定内核和 v0.1 mapper。
|
||||||
- Bell 事件校验、不可变存储和 ULID。
|
- Bell 事件校验、不可变存储和 ULID。
|
||||||
|
- T-015:建立 Bell Go 事件域基础,复制并校验冻结 v0.1 schema,由 Bell 生成平台 ULID,执行六项代码断言,并以 `bell_runtime` 最小权限保存不可变事件和 append-only outcome;不冻结 Brain transport 或公共 API。
|
||||||
- 规则引擎、场景包加载、预警状态机与双路径投递。
|
- 规则引擎、场景包加载、预警状态机与双路径投递。
|
||||||
- 最小 Web/App 处置流程、RBAC 与审计。
|
- 最小 Web/App 处置流程、RBAC 与审计。
|
||||||
- 现场误报基线和反馈队列。
|
- 现场误报基线和反馈队列。
|
||||||
|
- T-007:客户试点、借用或租赁设备条件具备后完成至少 5 条独立真实上游的现场验收;不阻塞本地开发,但阻塞生产试点启用和真实多路 SLA。
|
||||||
|
|
||||||
## M4:64 路分片与完整管理系统
|
## M4:64 路分片与完整管理系统
|
||||||
|
|
||||||
|
|||||||
+62
-14
@@ -2,28 +2,28 @@
|
|||||||
|
|
||||||
> 用户故事用于连接需求、页面交互和任务验收。当前 M0/M1 以设备与实施流程为主,最终用户界面在 M3/M4 才实现。
|
> 用户故事用于连接需求、页面交互和任务验收。当前 M0/M1 以设备与实施流程为主,最终用户界面在 M3/M4 才实现。
|
||||||
|
|
||||||
## US-001 批量开通摄像头
|
## US-001 批量开通视频设备
|
||||||
|
|
||||||
- 角色:实施工程师。
|
- 角色:实施工程师。
|
||||||
- 目标:一次导入并验证一个默认 16 路站点,不逐路手工配置。
|
- 目标:一次导入并验证一个默认 16 路视频站点,不逐路手工配置;操作入口位于通用设备台账的视频模态筛选下。
|
||||||
- 价值:降低交付时间并为 128 路扩展保留操作效率。
|
- 价值:降低交付时间并为 128 路扩展保留操作效率。
|
||||||
- 验收:支持待激活、逐设备结果、失败可重试、超配额明确拒绝;128 路规模下仍使用分页/批量流程。
|
- 验收:导入后先进入待激活,任务历史可查看逐设备成功/失败原因并仅重试失败项;认证失败时提供不回显旧密码的凭据更新路径;超配额明确拒绝;128 路规模下仍使用分页/批量流程。
|
||||||
- 关联:RQ-C-01~RQ-C-08,IX-001~IX-003。
|
- 关联:RQ-C-01~RQ-C-08,IX-001~IX-004、IX-015。
|
||||||
|
|
||||||
## US-002 查看设备与流健康
|
## US-002 查看设备与流健康
|
||||||
|
|
||||||
- 角色:平台运维/站点管理员。
|
- 角色:平台运维/站点管理员。
|
||||||
- 目标:快速知道哪些设备离线、时间漂移、流未收敛或分片异常。
|
- 目标:快速知道哪些设备离线、认证失败、时间漂移、流未收敛、隧道异常或分片异常,并能区分期望态与各下游实际态。
|
||||||
- 价值:在业务预警受影响前定位故障。
|
- 价值:在业务预警受影响前定位故障。
|
||||||
- 验收:设备期望态与实际态分开显示;断线重连可观察;业务预警和运维告警不混用。
|
- 验收:设备期望态与媒体/推理/存储实际态分开显示;对账差异、退避、下次重试和孤儿安全闸可观察;边缘隧道与视频数据面分别显示;业务预警和运维告警不混用。
|
||||||
- 关联:RQ-C-06~RQ-C-08、RQ-C-29、RQ-C-34,IX-004。
|
- 关联:RQ-C-06~RQ-C-08、RQ-C-29、RQ-C-34,IX-004、IX-019。
|
||||||
|
|
||||||
## US-003 处置业务预警
|
## US-003 处置业务预警
|
||||||
|
|
||||||
- 角色:家属/值班员。
|
- 角色:家属/值班员。
|
||||||
- 目标:收到异常后查看关联证据、确认接手并记录处置结果。
|
- 目标:收到异常后查看关联证据、确认接手并记录处置结果。
|
||||||
- 价值:把“发出通知”变成“有人负责”。
|
- 价值:把“发出通知”变成“有人负责”。
|
||||||
- 验收:首次投递、送达、看到、ack、升级分别可追溯;无 ack 自动升级;进程重启不丢失升级链。
|
- 验收:通道的已发出、已送达、已看到,与 Alert 的 ack、升级分别可追溯;无回执、通道不支持、可重试失败和最终失败不伪装成功;Alert 可查看聚合的 Event,Event 可查看触发的 Alert;并发 ack 时后到者看到真实处置人且不能覆盖;无 ack 自动升级,进程重启不丢失升级链。
|
||||||
- 关联:RQ-C-17~RQ-C-30,IX-005~IX-008。
|
- 关联:RQ-C-17~RQ-C-30,IX-005~IX-008。
|
||||||
|
|
||||||
## US-004 标记误报
|
## US-004 标记误报
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
## US-005 配置规则与升级链
|
## US-005 配置规则与升级链
|
||||||
|
|
||||||
- 角色:租户管理员/站点管理员。
|
- 角色:租户管理员/站点管理员。
|
||||||
- 目标:按站点和设备覆盖场景模板,配置区域、时段、持续时间和联系人升级链。
|
- 目标:按站点和设备覆盖场景模板,配置区域、时段、持续时间和升级链;升级步骤引用指定人员、值班组或值班排班,不直接写死手机号。
|
||||||
- 价值:同一通用底座适配不同场景。
|
- 价值:同一通用底座适配不同场景。
|
||||||
- 验收:继承来源清楚、变更可试运行/回滚、静默不超过 4 小时且自动恢复。
|
- 验收:继承来源清楚、变更可试运行;升级目标可预览当前解析人和通道,投递后保留解析快照;回滚先展示版本差异与影响,并创建新的不可变版本,不改写历史;静默不超过 4 小时且自动恢复。
|
||||||
- 关联:RQ-C-11~RQ-C-15、RQ-C-23~RQ-C-28,IX-010~IX-012。
|
- 关联:RQ-C-11~RQ-C-15、RQ-C-23~RQ-C-28,IX-010~IX-012。
|
||||||
|
|
||||||
## US-006 事件最小权限查看
|
## US-006 事件最小权限查看
|
||||||
@@ -50,14 +50,62 @@
|
|||||||
- 验收:越权返回统一拒绝;无授权租户看不到人脸能力;常态录像不因事件页面被间接暴露。
|
- 验收:越权返回统一拒绝;无授权租户看不到人脸能力;常态录像不因事件页面被间接暴露。
|
||||||
- 关联:RQ-S1-05~RQ-S1-07、RQ-S2-08、RQ-C-31~RQ-C-33,IX-013。
|
- 关联:RQ-S1-05~RQ-S1-07、RQ-S2-08、RQ-C-31~RQ-C-33,IX-013。
|
||||||
|
|
||||||
## US-007 兼容性实验记录
|
## US-007 指定型号准入记录
|
||||||
|
|
||||||
- 角色:M0 测试/实施工程师。
|
- 角色:M0 测试/实施工程师。
|
||||||
- 目标:对候选摄像头执行一致的 ONVIF 与恢复测试并形成采购白名单。
|
- 目标:对首期指定摄像头型号/固件执行一致的 ONVIF 与恢复测试并形成采购准入基线。
|
||||||
- 价值:在写生产接入代码前先识别厂商差异。
|
- 价值:在生产接入前确认统一采购基线可用,同时控制首期多品牌适配成本。
|
||||||
- 验收:3–5 款设备的型号、固件、认证方式、Profiles/StreamUri/校时、主子码流、掉线恢复都有证据;不记录密码和真实客户信息。
|
- 验收:至少一台真实样机的厂商、型号、硬件版本、固件、认证方式、Profiles/StreamUri/校时、主子码流和 3 次掉线恢复都有证据;明确单样本和非多品牌限制,不记录密码和真实客户信息。
|
||||||
- 关联:M0,T-001;无产品 UI,使用版本化测试文档。
|
- 关联:M0,T-001;无产品 UI,使用版本化测试文档。
|
||||||
|
|
||||||
|
## US-008 管理异构设备与隐私准入
|
||||||
|
|
||||||
|
- 角色:实施工程师/站点管理员。
|
||||||
|
- 目标:在统一设备台账中按模态和能力添加、筛选与查看视频、雷达、门磁、按钮、可穿戴等设备;隐私区域只允许非成像设备。
|
||||||
|
- 价值:M2 固化不会因 M6 异构设备接入而推倒重来的信息架构,同时把隐私要求落实为系统准入约束。
|
||||||
|
- 验收:一级入口使用“设备”;添加时先选模态,详情按探测能力显示页面并标明来源、时间与生效差异;未实现适配器显示 `adapter_not_ready`,不得伪装在线;`non_imaging_only` Area 不允许新增或启用成像设备,策略读取失败时拒绝新变更并给出恢复路径;M2 完成信息架构与视频准入,M6 完成非视频适配器。
|
||||||
|
- 关联:RQ-S1-05、NFR-CMP-05,IX-014、IX-016、IX-019。
|
||||||
|
|
||||||
|
## US-009 以最小权限运维接入系统
|
||||||
|
|
||||||
|
- 角色:平台运维/租户管理员/站点管理员/只读用户。
|
||||||
|
- 目标:进入 Sense 时明确当前租户、站点、账号和角色,并只看到权限范围内的数据与操作。
|
||||||
|
- 价值:让私有化单租户交付也保留 SaaS-ready 隔离边界,避免原型把管理员权限当成默认事实。
|
||||||
|
- 验收:切换站点会刷新上下文;只读角色看不到写操作;深链越权安全拒绝且不泄露设备存在性;设备操作日志可带筛选上下文跳到 Bell 全局审计。
|
||||||
|
- 关联:RQ-C-31~RQ-C-33,IX-013、IX-020。
|
||||||
|
|
||||||
|
## US-010 管理站点与区域准入策略
|
||||||
|
|
||||||
|
- 角色:租户管理员/站点管理员。
|
||||||
|
- 目标:在统一管理端维护 Site、Area 层级与 `capture_policy`,并让 Sense 使用同一版本的策略执行设备准入。
|
||||||
|
- 价值:避免 Area 在 Sense、Bell 各有一份真相,保证权限、报表和隐私准入口径一致。
|
||||||
|
- 验收:Bell 是 Site/Area/配额/策略真相源;策略变更影响已有成像设备时必须显式选择迁移或取消,不自动停用;Sense 展示策略版本与同步状态,投影不可用时仅拒绝相关新写入。
|
||||||
|
- 关联:RQ-S1-05、NFR-CMP-05,IX-016、IX-020。
|
||||||
|
|
||||||
|
## US-011 完成值班交接
|
||||||
|
|
||||||
|
- 角色:交班值班员/接班值班员。
|
||||||
|
- 目标:在班次结束前把所有未 ack、处置中和升级中的 Alert 连同备注交给下一班,并得到明确接手确认。
|
||||||
|
- 价值:保证“有人负责”不在班次边界中断。
|
||||||
|
- 验收:交接清单显示当前状态、处置人与下一次升级时间;接班人确认后写审计;交接过程不暂停、不重置升级链,未确认交接时原责任关系保持不变。
|
||||||
|
- 关联:RQ-C-17~RQ-C-30,IX-021。
|
||||||
|
|
||||||
|
## US-012 导出规则验收效果
|
||||||
|
|
||||||
|
- 角色:项目负责人/租户管理员/验收人员。
|
||||||
|
- 目标:按规则版本和冻结样本窗口查看可复核的召回率与每路每天误报数。
|
||||||
|
- 价值:让站点验收使用场景相关、可追溯的效果口径,避免跨场景“准确率”误导。
|
||||||
|
- 验收:报表展示规则版本、样本时间窗、正样本/漏检样本、通道天数、召回率和每路每天误报数;明确不提供跨场景统一准确率;导出保留筛选与口径说明。
|
||||||
|
- 关联:RQ-C-22、`02-requirements` §8,IX-022。
|
||||||
|
|
||||||
|
## US-013 管理联系人和值班排班
|
||||||
|
|
||||||
|
- 角色:租户管理员/站点管理员。
|
||||||
|
- 目标:使用同一套人员、值班组和通知通道主数据配置可发布的值班排班,并让升级策略引用排班目标。
|
||||||
|
- 价值:避免先把联系人和号码写死进升级链、后续再迁移到时段轮换;同时避免把联系人、班次和轮换混成一个难维护对象。
|
||||||
|
- 验收:联系人维护身份、角色、值班组和已验证通道,不出现排班轮换字段;排班维护时区、班次、周轮换、生效日期、临时替班和版本;发布前发现空档/重叠并阻止发布,可预览当前及未来值班人;投递固化收件人、通道与排班版本快照;交接进行中 Alert 不静默修改未来排班。
|
||||||
|
- 关联:RQ-C-24~RQ-C-27、RQ-C-33,IX-012、IX-023。
|
||||||
|
|
||||||
## 追溯规则
|
## 追溯规则
|
||||||
|
|
||||||
新增 P0 UI 任务必须引用至少一个 US 和一个 IX;若没有 UI,任务文件明确写“不适用”。需求变化先更新用户故事和交互清单,再改页面。
|
新增 P0 UI 任务必须引用至少一个 US 和一个 IX;若没有 UI,任务文件明确写“不适用”。需求变化先更新用户故事和交互清单,再改页面。
|
||||||
|
|||||||
@@ -4,19 +4,29 @@
|
|||||||
|
|
||||||
| ID | 场景 | 必须覆盖的状态与行为 | 关联 US | 阶段 |
|
| ID | 场景 | 必须覆盖的状态与行为 | 关联 US | 阶段 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| IX-001 | 批量导入设备 | 下载模板、上传校验、逐行错误、重复序列号、待激活、确认写入 | US-001 | M2 |
|
| IX-001 | 批量导入设备 | 下载模板、上传校验、逐行错误、重复序列号、待激活、确认写入;任务历史保留逐项结果、操作者与时间,支持仅重试失败项 | US-001 | M2 |
|
||||||
| IX-002 | 配额与批量结果 | 显示已用/上限;超 16 不等于非法,按站点配置校验;部分成功可重试 | US-001 | M2 |
|
| IX-002 | 配额与批量结果 | 显示已用/上限;超 16 不等于非法,按站点配置校验;部分成功可重试;配额不可读取时只禁用新增/启用并保留已有列表与流 | US-001 | M2 |
|
||||||
| IX-003 | 设备列表 | 分页/筛选/批量选择;128 路不一次加载所有视频和详情 | US-001 | M2/M4 |
|
| IX-003 | 设备列表 | 分页/筛选/批量选择;128 路不一次加载所有视频和详情 | US-001 | M2/M4 |
|
||||||
| IX-004 | 设备健康 | 期望态、实际态、最后在线、时间漂移、分片、重试进度;运维告警独立 | US-002 | M2/M4 |
|
| IX-004 | 设备健康 | 期望态、媒体/推理/存储实际态、认证失败、最后在线完整时间与站点时区、时间漂移、分片、重试进度;运维告警独立,凭据更新只允许写入新值且旧值永不回显 | US-002 | M2/M4 |
|
||||||
| IX-005 | 预警到达 | 明确严重度、站点、时间、证据可用性;重复投递不产生重复处置 | US-003 | M3 |
|
| IX-005 | 预警到达 | 明确严重度、站点、时间、证据可用性与关联事件数;重复投递不产生重复处置 | US-003 | M3 |
|
||||||
| IX-006 | ack | 一次点击可确认,显示确认人/时间;并发 ack 有清晰结果 | US-003 | M3 |
|
| IX-006 | ack | 一次点击可确认,显示确认人/时间;服务端首个成功者成为处置人,并发后到者看到当前处置人和冲突结果,不静默覆盖或显示双成功 | US-003 | M3 |
|
||||||
| IX-007 | 升级 | 显示当前层级、下一次升级时间、每次投递状态;失败不能伪装成功 | US-003 | M3 |
|
| IX-007 | 升级与投递 | 显示当前层级、下一次升级时间;每个通道分别展示已发出、已送达、已看到,Alert ack 单独展示;无回执/不支持/未知、可重试失败及最终失败不得伪装成功 | US-003 | M3 |
|
||||||
| IX-008 | 事件详情 | 结构化事实、抓拍、视频、时间线、处置;证据加载失败可重试且不丢元数据 | US-003 | M3/M4 |
|
| IX-008 | 事件详情 | 结构化事实、抓拍、视频、时间线、处置;证据加载失败可重试且不丢元数据;Alert 展示关联 Event 与聚合原因,Event 展示触发的 Alert 与最终状态,两边可深链;被抑制且未创建 Alert 的事件明确标为未触发 | US-003 | M3/M4 |
|
||||||
| IX-009 | 误报反馈 | 确认动作、可选原因、提交成功反馈;只改变 outcome,不改原始事件 | US-004 | M3 |
|
| IX-009 | 误报反馈 | 确认动作、可选原因、提交成功反馈;只改变 outcome,不改原始事件 | US-004 | M3 |
|
||||||
| IX-010 | 规则编辑 | 显示租户/站点/设备继承来源,支持区域/警戒线/时段/持续时间 | US-005 | M3/M4 |
|
| IX-010 | 规则编辑 | 显示租户/站点/设备继承来源,支持区域/警戒线/时段/持续时间 | US-005 | M3/M4 |
|
||||||
| IX-011 | 规则试运行 | 明确“未正式生效”,展示命中样本与影响范围,支持取消/回滚 | US-005 | M4 |
|
| IX-011 | 规则试运行与回滚 | 明确“未正式生效”,展示命中样本与影响范围,支持取消;回滚前展示目标版本差异与影响,确认后创建并发布等价的新版本,历史版本保持不可变;版本冲突时阻止覆盖并提供刷新/另存路径 | US-005 | M4 |
|
||||||
| IX-012 | 升级链/静默 | 联系人顺序、超时、双通道;静默最长 4h、显示自动恢复时间、无永久选项 | US-005 | M3/M4 |
|
| IX-012 | 升级链/静默 | 升级步骤通过类型化目标引用指定人员、值班组或值班排班,不写死号码;显示目标解析预览、联系人顺序、超时与双通道;每次投递固化实际收件人、通道和排班版本快照;静默最长 4h、显示自动恢复时间、无永久选项 | US-005、US-013 | M3/M4 |
|
||||||
| IX-013 | 权限与隐私 | 越权统一处理;无授权租户不展示人脸入口;不泄露流 URL/凭据 | US-006 | M3/M4 |
|
| IX-013 | 权限与隐私 | 越权统一处理;无授权租户不展示人脸入口;不泄露流 URL/凭据 | US-006 | M3/M4 |
|
||||||
|
| IX-014 | 设备模态与能力 | 一级入口为“设备”;列表可按 `modality` 筛选,添加时先选模态,详情按 `capabilities` 渐进展示;能力同时显示厂商/型号/固件/认证、探测来源与时间、探测值和生效值差异;视频设备显示画面/媒体/检测区域,未交付的非视频适配器显示 `adapter_not_ready`,不用虚构遥测或禁用的视频页签占位 | US-008 | M2/M6 |
|
||||||
|
| IX-015 | 停用与收敛 | 明确区分暂停推理订阅、停用设备接入和仅踢当前会话;暂停推理必须说明“解除推理侧订阅”,并在结果中显示推理订阅、其他 reader 数与上游按需拉流状态;操作前展示对观看、录制与证据回捞的影响并二次确认;期望态立即改变、实际态经对账收敛,部分失败可逐项重试;重复请求不产生重复副作用,但每次请求均留审计结果 | US-001、US-002 | M2 |
|
||||||
|
| IX-016 | 隐私区域设备准入 | Area 与 `capture_policy` 由 Bell 统一管理,Sense 消费带版本的只读投影;`non_imaging_only` 不允许新增/启用具有成像能力的设备;前端就地禁用并解释,后端返回稳定错误码;策略读取失败时拒绝新变更并告警,已有设备遇到策略变化时由 Bell 进入显式迁移/取消流程,不静默留存或停用 | US-008、US-010 | M2/M6 |
|
||||||
|
| IX-017 | 检测区域几何编辑 | 多边形和方向警戒线支持绘制、显式完成、撤销、清空、顶点编辑;警戒线在草稿/已保存态均显示方向箭头并可反转;鼠标与键盘坐标操作等效;画面参数变化后标记待校准且不自动改坐标;区域版本与规则版本独立 | US-005 | M3 |
|
||||||
|
| IX-018 | 草稿与跨系统上下文 | 几何草稿自动保存并可恢复;只有持久化失败或切换空间类型等会破坏草稿的动作才拦截确认;Sense↔Bell 双向携带并消费 `camera + zone + return_to` 上下文;返回后恢复草稿,版本已被他人更新时提示冲突且不覆盖 | US-005 | M3 |
|
||||||
|
| IX-019 | 运维中心与写入降级 | Sense 用一个一级“运维中心”聚合对账差异/退避/下次重试/孤儿安全闸、媒体与推理分片、边缘节点/隧道/补传、运维告警和系统状态;配额或 Area 策略不可读取时页面继续可读且已有链路不受影响,只禁用相关写操作;不得混入 Bell 业务预警 | US-002、US-008 | M2/M4/M6 |
|
||||||
|
| IX-020 | 管理上下文、RBAC 与审计归属 | 全局头部显示租户、站点、账号和角色;只读角色隐藏写操作,深链越权统一拒绝且不泄露资源是否存在;Tenant/Site/Area/RBAC/配额/全局审计由 Bell 管理,Sense 只呈现消费上下文和设备操作入口,携带 tenant/site/device/return_to 深链到 Bell 审计;Bell 移动端底部导航不超过 5 项,并通过可发现的“管理”入口访问 Site/Area 与审计 | US-006、US-009、US-010 | M2/M4 |
|
||||||
|
| IX-021 | 值班交接 | 清单覆盖未 ack、处置中和升级中的 Alert,显示交班人、接班人、备注及下一次升级时间;接班确认写审计,交接期间不暂停或重置升级链,未确认时不转移责任 | US-011 | M3 |
|
||||||
|
| IX-022 | 规则验收报表 | 按规则版本与冻结样本窗口展示召回率、每路每天误报数和计算样本量;支持保留口径的导出;不提供跨场景统一准确率 | US-012 | M4 |
|
||||||
|
| IX-023 | 联系人与值班排班 | 在“升级链”内部以升级策略、值班与排班、联系人和通道三个二级模块统一设计;联系人与排班共享人员/值班组/已验证通道主数据但分对象维护;排班覆盖站点时区、周轮换、生效日期、临时替班、空档/重叠冲突、当前/未来值班人预览和版本发布审计;交接班只转移进行中 Alert,不暗改未来排班 | US-005、US-011、US-013 | M3/M4 |
|
||||||
|
|
||||||
## 全局状态
|
## 全局状态
|
||||||
|
|
||||||
@@ -44,7 +54,10 @@
|
|||||||
## 无障碍与安全
|
## 无障碍与安全
|
||||||
|
|
||||||
- 键盘可完成主要 Web 流程,焦点清晰,表单错误关联到字段。
|
- 键盘可完成主要 Web 流程,焦点清晰,表单错误关联到字段。
|
||||||
- 文本和关键状态满足可读对比度;严重度同时用文字/图标表达。
|
- 正文、按钮文字和关键状态对比度不低于 4.5:1;大字号(不低于 24px,或不低于 18.66px 且粗体)不低于 3:1;非文本控件、状态边界与焦点指示器不低于 3:1。严重度同时用文字/图标表达。
|
||||||
|
- 当前导航项显式使用 `aria-current="page"`;响应式隐藏可见文字时,图标按钮仍保留稳定的可访问名。
|
||||||
|
- 使用 tab 模式时完整实现 `tablist` / `tab` / `tabpanel`、`aria-controls` / `aria-labelledby`、单一 Tab 停靠点和方向键导航;不能完整实现时不声明 tab 角色。
|
||||||
|
- 移动端主要操作目标不小于 44×44px,输入控件正文不小于 16px;绘制画布必须提供无需精确点击的键盘/表单等效路径。
|
||||||
- 删除、停用、踢流、批量覆盖和规则正式发布需要明确影响范围与二次确认。
|
- 删除、停用、踢流、批量覆盖和规则正式发布需要明确影响范围与二次确认。
|
||||||
- UI 不展示摄像头密码、完整连接串、token 或可复用的内部流地址。
|
- UI 不展示摄像头密码、完整连接串、token 或可复用的内部流地址。
|
||||||
|
|
||||||
|
|||||||
+54
-7
@@ -1,6 +1,6 @@
|
|||||||
# API 与契约
|
# API 与契约
|
||||||
|
|
||||||
> 事件契约 v0.1 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。
|
> Brain → Bell 事件契约 v0.1、Sense Control API v1、Bell 配额/Area 只读投影 v1 与 Sense 本地设备审计事件 v1/v2 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。
|
||||||
|
|
||||||
## 1. 已冻结:Brain → Bell 事件契约
|
## 1. 已冻结:Brain → Bell 事件契约
|
||||||
|
|
||||||
@@ -17,17 +17,39 @@
|
|||||||
- 证据文件名只含事件 ID 与日期目录,不含 IP、端口、凭据或客户名。
|
- 证据文件名只含事件 ID 与日期目录,不含 IP、端口、凭据或客户名。
|
||||||
- `sensors` 中恰有一个 primary,且其 `device_id` 与顶层一致。
|
- `sensors` 中恰有一个 primary,且其 `device_id` 与顶层一致。
|
||||||
|
|
||||||
## 2. 待冻结的内部接口
|
T-015 已实现 Bell 消费端的内部组装与存储边界:可信 ingress 先接收“不含平台 `id`”的候选事实,Bell 生成 `evt_` ULID 后形成最终 v0.1 对象,再执行 schema 与六项代码断言并不可变落库。该候选类型是 Bell 内部 port,不是 Brain 可依赖的 HTTP/消息总线协议;transport、认证和重放语义仍由后续任务冻结。
|
||||||
|
|
||||||
|
## 2. 跨系统接口状态
|
||||||
|
|
||||||
| 调用方 → 提供方 | 用途 | 当前约束 | 状态 |
|
| 调用方 → 提供方 | 用途 | 当前约束 | 状态 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Sense → Bell | 读取站点视频配额 | 版本化;默认 16、最大 128;失败时拒绝新增/启用但不影响已有流 | 待 M2 设计 |
|
| Sense → Bell | 读取站点视频配额 | 同一 PostgreSQL 实例内只读 `bell.site_quota_v1`;默认 16、最大 128;失败时拒绝新增/启用但不影响已有流 | T-008 冻结,T-009 已实现数据路径 |
|
||||||
|
| Sense → Bell | 读取 Area 成像准入 | 只读 `bell.area_policy_v1`;`video_allowed | non_imaging_only`;缺失/非法/回退失败关闭但不影响已有设备 | T-010 已冻结并实现数据路径 |
|
||||||
|
| Sense → Bell | 汇入设备操作审计 | 本地 Outbox 事件已冻结并原子落库;transport、签名、确认与留存未冻结 | T-010 本地基础已实现,relay 待设计 |
|
||||||
| Bell → Sense | 请求事件证据/pre-roll 切片 | 幂等、按租户授权、异步结果、不得暴露原始凭据 | 待 M3 设计 |
|
| Bell → Sense | 请求事件证据/pre-roll 切片 | 幂等、按租户授权、异步结果、不得暴露原始凭据 | 待 M3 设计 |
|
||||||
| Bell → Brain | outcome/误报反馈 | 原事件不可变;反馈可重试、去重、审计 | 待 M3 设计 |
|
| Bell → Brain | outcome/误报反馈 | 原事件不可变;反馈可重试、去重、审计 | 待 M3 设计 |
|
||||||
| Sense → Brain | 流绑定与设备型触发 | 分片可路由,触发入口与流控制解耦 | 待 M2/M3 设计 |
|
| Sense → Brain | 流绑定与设备型触发 | 分片可路由,触发入口与流控制解耦 | 待 M2/M3 设计 |
|
||||||
| Worker → 控制面 | 注册、心跳、容量 | `max_sources` 来自 profile/压测,不固定为 16 | 待 M3 设计 |
|
| Worker → 控制面 | 注册、心跳、容量 | `max_sources` 来自 profile/压测,不固定为 16 | 待 M3 设计 |
|
||||||
|
|
||||||
## 3. 待冻结的 Bell 公共 API
|
冻结签名和失败语义见 [`contracts/README.md`](contracts/README.md)、[`contracts/site-quota-v1.sql`](contracts/site-quota-v1.sql)、[`contracts/area-policy-v1.sql`](contracts/area-policy-v1.sql) 与 [`contracts/sense-device-audit-v1.schema.json`](contracts/sense-device-audit-v1.schema.json)。Bell 拥有投影源数据和视图,Sense 数据库角色只有 `SELECT`;未来分库必须发布新版本,不能在 v1 下把本地视图静默替换为网络调用。
|
||||||
|
|
||||||
|
## 3. 已冻结:Sense Control API v1
|
||||||
|
|
||||||
|
- OpenAPI:[`contracts/sense-control-v1.openapi.json`](contracts/sense-control-v1.openapi.json)
|
||||||
|
- 语义、资源所有权、幂等、并发与兼容规则:[`contracts/README.md`](contracts/README.md)
|
||||||
|
- 范围:设备分页查询、创建、读取、修改、单项启停、最多 128 项的批量启停和批量操作查询。
|
||||||
|
|
||||||
|
关键规则:
|
||||||
|
|
||||||
|
- 所有业务路径使用 `/api/v1` 和 Bearer 认证;tenant 只来自认证上下文,跨租户访问与不存在统一为 `404 not_found`。
|
||||||
|
- Site、Area、RBAC、配额和全局审计仍由 Bell 持有;Sense 只管理 Device 期望态和收敛状态,不提供这些 Bell 资源的 CRUD。
|
||||||
|
- 列表使用稳定顺序和不透明 cursor,默认 50、最大 100;创建与批量写要求 `Idempotency-Key`,资源修改与单项期望态写要求 `If-Match`。
|
||||||
|
- `endpoint_ref`、`credential_ref`、`profile_token` 只写不读;设备 ID 由服务端生成。普通响应和错误不得包含凭据、完整流 URI、token 或 MediaMTX 内部配置。
|
||||||
|
- v1 不提供删除设备;停用设备保留历史。写入受理只表示期望态已持久化,不能表示实际态已收敛。
|
||||||
|
|
||||||
|
T-008 冻结公共控制契约;T-009/T-010 建立 PostgreSQL 投影、准入和本地审计基础;T-011 已实现 7 个 HTTP handler、外部静态摘要认证适配器、tenant/Site scope、幂等收据、ETag/HMAC cursor 和持久化 batch operation。业务路由默认关闭且仅可在 PostgreSQL 上开启;Bell 管理服务、JWT/OIDC 和 Outbox relay 尚未实现。
|
||||||
|
|
||||||
|
## 4. 待冻结的 Bell 公共 API
|
||||||
|
|
||||||
资源范围预计包括:租户、站点、设备只读投影、规则、事件、预警、ack、处置、误报反馈、审计和报表。设计时必须满足:
|
资源范围预计包括:租户、站点、设备只读投影、规则、事件、预警、ack、处置、误报反馈、审计和报表。设计时必须满足:
|
||||||
|
|
||||||
@@ -38,15 +60,40 @@
|
|||||||
- 错误体包含稳定错误码、可读消息和 trace ID,不返回内部堆栈或凭据。
|
- 错误体包含稳定错误码、可读消息和 trace ID,不返回内部堆栈或凭据。
|
||||||
- 人脸功能未授权时表现为能力不存在,而非仅按钮置灰。
|
- 人脸功能未授权时表现为能力不存在,而非仅按钮置灰。
|
||||||
|
|
||||||
## 4. MediaMTX 接口边界
|
## 5. MediaMTX 接口边界
|
||||||
|
|
||||||
Sense 使用 MediaMTX 官方 OpenAPI 生成客户端并加薄封装。业务代码不得散落硬编码 path API;生成代码不可手改。MediaMTX path 不是租户/站点/设备的业务真相源。
|
Sense 使用 MediaMTX 官方 OpenAPI 生成客户端并加薄封装。业务代码不得散落硬编码 path API;生成代码不可手改。MediaMTX path 不是租户/站点/设备的业务真相源。
|
||||||
|
|
||||||
## 5. 变更流程
|
### 5.1 T-003 已实现的内部适配契约
|
||||||
|
|
||||||
|
以下是 Sense 内部 Go port,不是 Bell 或第三方可依赖的公共 HTTP API:
|
||||||
|
|
||||||
|
| Port | 操作 | 数据所有者 / 失败语义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ONVIF adapter | `Probe(target)`、`SetSystemDateAndTime(target, time)` | 设备是外部来源;`target` 只含 endpoint ref 与不透明 credential ref。错误稳定映射为认证失败、超时、不可用、响应无效,不记录凭据或完整流地址 |
|
||||||
|
| MediaMTX paths | `CreatePath`、`GetPath`、`EnsurePath`、`DeletePath`、`PathReady`、`ListPathNames` | 设备台账持有期望态,MediaMTX 只持有运行配置;分页枚举只返回名称、不返回 source。普通调和只操作精确台账 Path;独立孤儿流程只报告未知归属,受控处置只接受有历史归属的 stale Path |
|
||||||
|
| Device repository | 设备、期望态、实际态、调和进度 | SQLite 是 M1 默认开发路径;PostgreSQL 是 M2 生产路径并只读 Site/Area 两个 Bell 视图。调和退避持久化;配额/Area 失败时拒绝相关新增或启用,不关闭已有流;高风险写入与脱敏 Outbox 同事务 |
|
||||||
|
|
||||||
|
MediaMTX 薄封装调用同版官方 OpenAPI 的 `/v3/config/paths/get|add|patch|delete/{name}`、`/v3/config/paths/list` 与 `/v3/paths/get/{name}`。列表有最大页数和重复页保护,source URI 在薄封装内丢弃。生成源、版本和 SHA-256 见 `docs/03-tech-stack.md`;业务包不得直接 import 生成包。
|
||||||
|
|
||||||
|
### 5.2 设备台账语义
|
||||||
|
|
||||||
|
- 设备类型由 `modality` 表达物理类别,由多值 `capabilities` 表达视频采集、音频、空间规则或遥测能力,避免把“摄像头”固化为唯一设备模型。
|
||||||
|
- 视频配额只统计 `desired_state=enabled` 且具有 `video_capture` capability 的设备;站点默认 16、可配置 1~128。禁用设备和非视频传感器不占视频路数。
|
||||||
|
- SQLite 表使用 `sense_` 前缀且只是 M1 实验室兼容路径;PostgreSQL 使用 `sense.devices`、能力/调和表、两个投影观察表和 `sense.device_operation_outbox`。PostgreSQL 不建立可写 Site/Area 真相副本;后续公共控制 API 只在 PostgreSQL 路径启用,不能把 SQLite 描述为 Area/Outbox 生产等价实现。
|
||||||
|
- 摄像头密码不进入设备普通字段。`credential_ref` 只保存外部密钥引用;ONVIF 返回的 stream URI 只在内存中传给 MediaMTX,不写入设备台账或日志。
|
||||||
|
|
||||||
|
### 5.3 Sense 进程 HTTP 面
|
||||||
|
|
||||||
|
`GET /healthz` 表示进程存活,`GET /readyz` 表示所选数据库已打开且 schema/权限前置检查完成;两者不要求认证,也不等价于摄像头、MediaMTX path 或里程碑健康。`GET /metrics` 默认输出无租户/设备/Path 标签的 Prometheus 汇总,可通过 `SENSE_METRICS_ENABLED=false` 关闭。T-011 在 `SENSE_CONTROL_API_ENABLED=true`、PostgreSQL v5 schema 和外部安全文件全部有效时注册冻结的 `/api/v1` 路由;默认 SQLite 不注册业务路由。业务路由不提供无 Site 边界的 `/api/v1/devices` 临时接口。
|
||||||
|
|
||||||
|
孤儿报告/处置不是公共 HTTP API。它由 PostgreSQL 专用 `cmd/sense-orphan` 在受控运维主机执行,使用 15 分钟 scan ID、actor、精确确认文本和运行前二次快照;不改变 Sense Control API v1 的 7 个 endpoint。
|
||||||
|
|
||||||
|
## 6. 变更流程
|
||||||
|
|
||||||
1. 在对应任务文件写清调用方、提供方、数据所有者、失败语义、幂等与兼容策略。
|
1. 在对应任务文件写清调用方、提供方、数据所有者、失败语义、幂等与兼容策略。
|
||||||
2. 更新本文和 schema/OpenAPI。
|
2. 更新本文和 schema/OpenAPI。
|
||||||
3. 同步生产者、消费者、契约测试和示例。
|
3. 同步生产者、消费者、契约测试和示例。
|
||||||
4. 记录迁移、回滚与版本废弃策略。
|
4. 记录迁移、回滚与版本废弃策略。
|
||||||
|
|
||||||
事件 v0.1 的破坏性变化必须发布新版本,不能原地修改已被 M3 生产者/消费者使用的契约。
|
事件 v0.1、Sense Control API v1、配额/Area 投影 v1 或本地设备审计 v1 的破坏性变化必须发布新版本,不能原地修改已被生产者/消费者使用的契约。
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Sense 控制面、准入投影与本地审计契约 v1
|
||||||
|
|
||||||
|
> 冻结日期:2026-08-07。Control API 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额与全局审计的所有者。T-011 已实现 Control API handler 与 PostgreSQL 一致性边界;Bell 管理服务、JWT/OIDC 和 Outbox relay 仍未实现。
|
||||||
|
|
||||||
|
## 契约文件
|
||||||
|
|
||||||
|
| 文件 | 生产者 / 所有者 | 消费者 | 用途 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| [`sense-control-v1.openapi.json`](sense-control-v1.openapi.json) | Sense | Bell 管理面、受控集成方 | 设备查询、创建、修改、启停与批量操作 |
|
||||||
|
| [`site-quota-v1.sql`](site-quota-v1.sql) | Bell | Sense | 单 PostgreSQL 实例内的站点视频配额只读投影 |
|
||||||
|
| [`area-policy-v1.sql`](area-policy-v1.sql) | Bell | Sense | Area 归属与 `capture_policy` 只读投影 |
|
||||||
|
| [`sense-device-audit-v1.schema.json`](sense-device-audit-v1.schema.json) | Sense | 本地 Outbox;未来 Bell relay | 脱敏设备操作审计事实,不包含传输协议 |
|
||||||
|
| [`sense-device-audit-v2.schema.json`](sense-device-audit-v2.schema.json) | Sense | 本地 Outbox;未来 Bell relay | v1 后继,增加脱敏配置修改受理事实;v1 文件保持不变 |
|
||||||
|
|
||||||
|
OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。
|
||||||
|
|
||||||
|
## HTTP 资源与操作
|
||||||
|
|
||||||
|
| 操作 | 路径 | 关键约束 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 列出 / 创建设备 | `GET/POST /api/v1/sites/{site_id}/devices` | 列表使用不透明 cursor,默认 50、最大 100;创建要求 `Idempotency-Key`,设备 ID 由服务端生成 |
|
||||||
|
| 读取 / 修改设备 | `GET/PATCH /api/v1/sites/{site_id}/devices/{device_id}` | 修改要求 `If-Match`,版本不匹配返回 `412 etag_mismatch` |
|
||||||
|
| 修改期望态 | `PUT /api/v1/sites/{site_id}/devices/{device_id}/desired-state` | 要求 `If-Match`;受理不表示实际态已经收敛 |
|
||||||
|
| 批量修改期望态 | `POST /api/v1/sites/{site_id}/devices:batchDesiredState` | 要求 `Idempotency-Key`,最多 128 项,异步返回逐项结果 |
|
||||||
|
| 查询批量操作 | `GET /api/v1/operations/{operation_id}` | 只返回当前租户和站点可见的操作 |
|
||||||
|
|
||||||
|
列表按 `created_at ASC, id ASC` 稳定排序,cursor 是服务端生成的不透明位置标记。客户端不得解析或拼接 cursor;服务端可以在兼容范围内改变编码。批量不是跨设备全有或全无事务:每项独立接受或拒绝,成功项继续收敛,失败项带稳定错误码。请求内重复 `device_id` 视为对应项 `invalid_request`,不得用“最后一项覆盖”。重试失败项应使用新幂等键;原样重放整个请求必须返回原收据。
|
||||||
|
|
||||||
|
## 身份、租户与敏感信息
|
||||||
|
|
||||||
|
- 所有 `/api/v1` 操作都需要 Bearer 认证;具体 token 格式由认证任务冻结。`tenant_id` 只从认证上下文取得,body、query 和 path 均不能自报 tenant。
|
||||||
|
- 跨租户访问与资源不存在都返回 `404 not_found`,不得用状态码、消息或耗时泄露资源是否存在。授权范围不足但不涉及资源枚举时返回 `403 forbidden`。
|
||||||
|
- `endpoint_ref`、`credential_ref` 和 `profile_token` 是 write-only 输入。不允许 userinfo 形式的完整 RTSP URI;响应、错误、日志与示例不得包含这些引用、密码、token、完整连接串或 MediaMTX 内部配置。
|
||||||
|
- 创建设备显式提交 `modality + capabilities`;`video` 模态必须包含 `video_capture`,任何请求了 `video_capture` 的设备都必须同时提供 endpoint 与 credential 引用。服务端仍须由适配器验证能力,不能把客户端声明当作探测成功。
|
||||||
|
- `ETag` 表示设备资源版本;`If-Match` 缺失返回 `428 precondition_required`,过期版本返回 `412 etag_mismatch`。相同期望态重复提交不增加 generation,但每次受理仍可产生审计记录。
|
||||||
|
|
||||||
|
`Idempotency-Key` 的作用域是“认证主体 + tenant + site + operation + key”,服务端至少保存 24 小时。同作用域、同请求体重放返回首次状态码和响应;同 key 不同请求体返回 `409 idempotency_conflict`。幂等收据不等价于实际态完成。
|
||||||
|
|
||||||
|
## 配额投影与准入
|
||||||
|
|
||||||
|
Bell migration 最终创建 `bell.site_quota_v1`,列顺序和含义固定如下:
|
||||||
|
|
||||||
|
| 列 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `tenant_id`、`site_id` | 与两个 schema 共享的稳定逻辑 ID |
|
||||||
|
| `max_video_channels` | 默认 16,有效范围 1~128 |
|
||||||
|
| `source_version` | 站点投影每次变更后单调递增的版本 |
|
||||||
|
| `source_updated_at` | Bell 源记录的更新时间 |
|
||||||
|
|
||||||
|
视图由 `bell_app` 拥有;`sense_app` 只有 Bell schema 的 `USAGE` 和该视图的 `SELECT`,没有 `INSERT`、`UPDATE`、`DELETE` 或 Bell 源表权限。Sense 不得通过任何旁路写 Bell schema。首期使用同一 PostgreSQL 实例及 `sense`/`bell` schema;如果未来分库,必须发布新版本的网络契约,不能在 v1 下静默改变一致性和失败语义。
|
||||||
|
|
||||||
|
配额只统计 `desired_state=enabled` 且 capabilities 含 `video_capture` 的设备。创建已启用视频设备或把视频设备启用时,Sense 必须在同一设备写路径读取并记录所用 `source_version`,同时验证 Area 策略投影。降低配额不会自动停用已有设备;若当前占用已超限,后续创建/启用返回 `409 quota_exceeded`。配额行缺失、越界、版本回退或投影不可读时返回 `503 quota_projection_unavailable`,只阻止相关创建/启用,读取、非准入属性修改和停用仍允许,已有流保持运行。
|
||||||
|
|
||||||
|
T-010 冻结 `bell.area_policy_v1` 的列顺序为 `tenant_id/site_id/area_id/capture_policy/source_version/source_updated_at`,策略仅允许 `video_allowed | non_imaging_only`。Sense 对所有 PostgreSQL 新建设备验证 Area 归属;具有 `video_capture` 能力的设备在创建(包括 disabled 创建)和启用时检查策略。缺失、非法、版本回退或不可读映射为 `area_policy_unavailable`,`non_imaging_only` 拒绝成像变更并映射为 `area_policy_denied`。同库视图实时读取,不把长期未修改记录的 `source_updated_at` 年龄误判为过期。
|
||||||
|
|
||||||
|
## 本地设备操作审计
|
||||||
|
|
||||||
|
`sense-device-audit-v1.schema.json` 继续冻结创建与期望态两类事实且不原地扩展严格枚举。T-011 新增 v2 后继,兼容 v1 两类事件并增加 `device.configuration.accepted`;该 payload 只保存是否变化、字段名和 Area 逻辑 ID,不保存字段值。主体类型为 `user | service | system`,投影版本与 generation 随事实保存;endpoint、credential、profile token、path、密码、完整流 URI 或 MediaMTX 配置始终禁止进入审计。
|
||||||
|
|
||||||
|
PostgreSQL repository 必须在设备创建/期望态事务内写 `sense.device_operation_outbox`;Outbox 失败回滚业务写入。相同期望态不增加 generation,但仍产生独立审计事实。Schema 不是 Bell relay 协议:传输端点、签名、批量确认、重放窗口和留存由后续任务冻结。
|
||||||
|
|
||||||
|
## 兼容与废弃
|
||||||
|
|
||||||
|
- v1 可增加不改变已有语义的可选响应字段和新错误细节;客户端必须忽略未知响应字段。
|
||||||
|
- 删除/重命名字段、收紧已接受输入、改变状态码/幂等作用域/配额计数或把只读视图改为远程调用,均属于破坏性变化,必须发布新版本。
|
||||||
|
- 废弃版本应先在 OpenAPI 标记并公告迁移窗口;服务端在所有已声明消费者完成迁移前继续提供旧版本。
|
||||||
|
- OpenAPI 中的稳定错误码用于程序判断,`message` 只用于人读,不得依赖其字面内容。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
从仓库根目录执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m json.tool docs/contracts/sense-control-v1.openapi.json | Out-Null
|
||||||
|
python -m json.tool docs/contracts/sense-device-audit-v2.schema.json | Out-Null
|
||||||
|
python -m unittest discover -s tests -p "test_sense_control_contract.py"
|
||||||
|
python -m unittest discover -s tests -p "test_sense_control_implementation.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
测试同时校验 OpenAPI 结构、生成 server glue、HTTP handler 与 PostgreSQL migration/事务;它不替代 Bell 消费方联合验收或客户现场容量验证。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user