52 Commits
Author SHA1 Message Date
ila 2e04737922 Merge T-017 issue mapping
Maps T-017 to Gitea issue 59.
2026-08-11 10:30:30 +08:00
QiuSW 2768cfeacb docs(tasks): map T-017 to issue 59
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 10:18:40 +08:00
ila 5c9318ceef Merge T-017 task definition
Defines the scoped Brain single-stream visual prototype task.
2026-08-11 10:17:33 +08:00
QiuSW 5de6f0c2bb docs(tasks): define T-017 Brain visual prototype
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 10:17:07 +08:00
ila 2b9a59fe8a Merge T-016 Sense audit relay
Harness governance / validate (push) Has been cancelled
Implements immutable Bell audit storage and fenced Sense Outbox delivery.
2026-08-11 00:26:08 +08:00
QiuSW 4be2386421 feat: deliver Sense audits to Bell (T-016)
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 00:24:32 +08:00
ila aca22f4667 Merge pull request '[T-016] 回填 Gitea Issue 映射' (#56) from governance/T-016-map into main
Harness governance / validate (push) Has been cancelled
2026-08-10 23:59:15 +08:00
QiuSW 88b92d8811 docs(tasks): map T-016 to issue 55
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:59:00 +08:00
ila 325e6ec255 Merge pull request '[T-016] 定义 Sense 到 Bell 审计 relay 任务' (#54) from bootstrap/T-016 into main
Harness governance / validate (push) Has been cancelled
2026-08-10 23:58:23 +08:00
QiuSW e3cb1c5bc5 docs(tasks): define T-016 audit relay
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:58:03 +08:00
ila 7e66fab042 Merge pull request '[T-015] 建立 Bell 不可变事件存储' (#53) from agent/codex/T-015 into main
Harness governance / validate (push) Has been cancelled
2026-08-10 23:54:27 +08:00
QiuSW 236964d242 feat(bell): add immutable event store [T-015]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:53:11 +08:00
ila 5b42f58f23 Merge pull request '[T-015] 回填 Gitea Issue 映射' (#52) from governance/T-015-map into main
Harness governance / validate (push) Has been cancelled
2026-08-10 23:37:22 +08:00
QiuSW 8b11b39093 docs(tasks): map T-015 to issue 51
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:37:08 +08:00
ila 31b08454f9 Merge pull request '[T-015] 定义 Bell 不可变事件存储任务' (#50) from bootstrap/T-015 into main
Harness governance / validate (push) Has been cancelled
2026-08-10 23:36:19 +08:00
QiuSW cefa6275bf docs(tasks): define T-015 Bell immutable events
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:36:01 +08:00
ila 5ab212cb5c Merge pull request '[T-014] 建立 Sense 本地 16 路批量收敛与容量基线' (#49)
Harness governance / validate (push) Has been cancelled
Closes #47
2026-08-10 23:18:52 +08:00
QiuSW b9e21a95d1 docs(task): record T-014 16-stream baseline
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:16:12 +08:00
QiuSW d029067aa7 fix(lab): refresh first T-014 batch ETags [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-10 22:39:13 +08:00
QiuSW d355f3d069 fix(lab): report frozen Sense Go toolchain [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-10 22:35:25 +08:00
QiuSW 71e5e6e599 fix(lab): schedule all T-014 stability samples [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 11:18:46 +08:00
QiuSW c3cfaff614 fix(lab): release T-014 process handles before cleanup [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 10:45:23 +08:00
QiuSW 707ca8e508 fix(lab): avoid PostgreSQL pipeline deadlock [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 10:11:36 +08:00
QiuSW 403fd6e554 fix(lab): bound T-014 process cleanup [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 09:52:28 +08:00
QiuSW 5d222436f1 feat(lab): add 16-stream capacity harness [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 09:10:40 +08:00
QiuSW 3af6a16740 chore(task): claim T-014
Harness governance / validate (push) Has been cancelled
2026-08-08 08:56:00 +08:00
ila 8ebb8ced49 Merge pull request '[T-014] 回填 Gitea Issue 映射' (#48) from governance/T-014-map into main
Harness governance / validate (push) Has been cancelled
2026-08-08 08:54:35 +08:00
QiuSW 2b4ec7053b docs(task): map T-014 to Gitea issue 47
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-08 08:54:17 +08:00
ila 8f4717a322 Merge pull request '[T-014] 冻结本地 16 路批量收敛与容量基线规格' (#46) from governance/T-014-define into main
Harness governance / validate (push) Has been cancelled
2026-08-08 08:53:28 +08:00
QiuSW 951508b8b4 docs(task): define T-014 capacity baseline
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-08 08:52:59 +08:00
ila a8f50cf79f Merge pull request '[T-012] 补齐 Sense 对账安全闸、孤儿受控处置与多实例可观测性' (#45) from agent/codex/T-012 into main
Harness governance / validate (push) Has been cancelled
2026-08-07 23:01:18 +08:00
QiuSW 12857fdf32 feat(sense): add reconciliation safety controls [T-012]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 23:00:03 +08:00
QiuSW 677ed732f7 chore(task): claim T-012
Harness governance / validate (push) Has been cancelled
2026-08-07 22:28:16 +08:00
ila 25357723a0 Merge pull request '[T-012] 回填 Gitea Issue 映射' (#44) from governance/T-012-map into main
Harness governance / validate (push) Has been cancelled
2026-08-07 22:26:01 +08:00
QiuSW b7a4a0faf8 docs(task): map T-012 to Gitea issue 43
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 22:25:42 +08:00
ila 872b6c9a6c Merge pull request '[T-012] 冻结对账安全闸与多实例可观测性规格' (#42) from governance/T-012-define into main
Harness governance / validate (push) Has been cancelled
2026-08-07 22:24:43 +08:00
QiuSW 6f65121987 docs(task): define T-012 reconcile safety work
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 22:24:20 +08:00
ila 6757ef2c05 Merge pull request '[T-011] 实现 Sense Control API v1 与 PostgreSQL 一致性边界' (#41) from agent/codex/T-011 into main
Harness governance / validate (push) Has been cancelled
2026-08-07 20:57:18 +08:00
QiuSW a0d239811f feat(sense): implement Control API v1 [T-011]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 20:54:41 +08:00
QiuSW 0d3d7a22ed chore(task): renew T-011 write paths
Harness governance / validate (push) Has been cancelled
2026-08-07 20:39:59 +08:00
QiuSW 20b06884ad chore(task): claim T-011
Harness governance / validate (push) Has been cancelled
2026-08-07 20:14:37 +08:00
ila 6402d4384d Merge pull request '[T-011] 回填 Gitea Issue 映射' (#40) from governance/T-011-map into main
Harness governance / validate (push) Has been cancelled
2026-08-07 20:12:34 +08:00
QiuSW e8121a6a8e docs(task): map T-011 to Gitea issue 39
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 20:12:21 +08:00
ila 907d50d343 Merge pull request '[T-011] 定义 Sense Control API v1 实现任务' (#38) from governance/T-011-define into main
Harness governance / validate (push) Has been cancelled
2026-08-07 20:11:38 +08:00
QiuSW 3c1cce9d5f docs(task): define T-011 control API implementation
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 20:11:14 +08:00
ila 31ac2c40ac [T-010] 建立 Area 准入投影与设备操作审计 Outbox 基础
Harness governance / validate (push) Has been cancelled
Closes #35
2026-08-07 18:24:47 +08:00
QiuSW 36786723e3 feat(store): add Area admission and audit outbox [T-010]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 18:23:24 +08:00
QiuSW 8abff0a566 chore(task): claim T-010
Harness governance / validate (push) Has been cancelled
2026-08-07 18:08:20 +08:00
ila 0b40cf16c3 Governance: map T-010 to Gitea issue 35
Harness governance / validate (push) Has been cancelled
Complete the task-to-Issue mapping before claim.
2026-08-07 18:06:49 +08:00
QiuSW 997e515152 docs(task): map T-010 to Gitea issue 35
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 18:06:29 +08:00
ila c188c4c9e0 Governance: define T-010 task
Harness governance / validate (push) Has been cancelled
Add T-010 specification before Issue creation.
2026-08-07 18:05:42 +08:00
QiuSW a9323bc9de docs(task): define T-010 area audit foundation
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 18:05:20 +08:00
108 changed files with 14340 additions and 209 deletions
+4 -1
View File
@@ -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
View File
@@ -1 +0,0 @@
+30
View File
@@ -0,0 +1,30 @@
# Bell 事件存储与内部审计入口
Bell 当前实现 M3 的事件域基础及 Sense 审计 relay 的最小内部 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 权限;仅可在短期 `audit_relay_receipts` 表查询、插入和清理过期收据。
- `cmd/bell-api` 默认只监听 `127.0.0.1:8081`,接收 HMAC 签名的 `/internal/v1/audit-events:batch`,把脱敏设备操作事实追加到 `bell.audit_events`。
- `(key_id, nonce)` 收据保存 10 分钟;相同摘要重放原结果,不同摘要返回冲突。非回环监听必须配置 TLS 证书和私钥。
Brain→Bell transport、公共认证/事件 API、规则、Alert 和证据对象存储仍需后续任务冻结。审计 relay 只服务 Sense,不得把 `internal/event` 的 Go 类型或该 HMAC 适配器当成公共协议。
启动内部 receiver 前必须私下设置 `BELL_DB_DSN` 和仓库外绝对路径 `BELL_AUDIT_KEYS_FILE`。远端监听还必须设置 `BELL_TLS_CERT_FILE`、`BELL_TLS_KEY_FILE`;仓库不保存 DSN、key 或证书:
```powershell
go -C Bell run ./cmd/bell-api
```
## 验证
```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 服务。
+152
View File
@@ -0,0 +1,152 @@
package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"yovision/bell/internal/audit"
"yovision/bell/internal/store"
)
var version = "dev"
type configuration struct {
address string
dsn string
keyFile string
tlsCert string
tlsKey string
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := run(logger); err != nil {
logger.Error("Bell stopped", "error", err)
os.Exit(1)
}
}
func loadConfiguration() (configuration, error) {
value := configuration{
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
dsn: os.Getenv("BELL_DB_DSN"),
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
}
if value.dsn == "" {
return configuration{}, errors.New("BELL_DB_DSN is required")
}
if value.keyFile == "" || !filepath.IsAbs(value.keyFile) {
return configuration{}, errors.New("BELL_AUDIT_KEYS_FILE must be an absolute external path")
}
host, _, err := net.SplitHostPort(value.address)
if err != nil {
return configuration{}, errors.New("invalid BELL_HTTP_ADDR")
}
ip := net.ParseIP(host)
loopback := host == "localhost" || (ip != nil && ip.IsLoopback())
if !loopback && (value.tlsCert == "" || value.tlsKey == "" || !filepath.IsAbs(value.tlsCert) || !filepath.IsAbs(value.tlsKey)) {
return configuration{}, errors.New("non-loopback Bell bind requires absolute TLS certificate and key paths")
}
if (value.tlsCert == "") != (value.tlsKey == "") {
return configuration{}, errors.New("Bell TLS certificate and key must be configured together")
}
return value, nil
}
func run(logger *slog.Logger) error {
cfg, err := loadConfiguration()
if err != nil {
return err
}
pgConfig, err := pgx.ParseConfig(cfg.dsn)
if err != nil {
return errors.New("invalid Bell postgres DSN")
}
if pgConfig.RuntimeParams == nil {
pgConfig.RuntimeParams = make(map[string]string)
}
pgConfig.RuntimeParams["application_name"] = "yovision-bell"
db := stdlib.OpenDB(*pgConfig)
db.SetMaxOpenConns(16)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(30 * time.Minute)
defer db.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
repository, err := store.OpenPostgres(ctx, db)
if err != nil {
return err
}
if err := repository.AuditRelayReady(ctx); err != nil {
return err
}
keys, err := audit.LoadKeys(cfg.keyFile)
if err != nil {
return err
}
handler, err := audit.NewHandler(repository, keys)
if err != nil {
return err
}
mux := http.NewServeMux()
mux.Handle(audit.RelayPath, handler)
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
writeStatus(writer, http.StatusOK, "ok")
})
mux.HandleFunc("GET /readyz", func(writer http.ResponseWriter, request *http.Request) {
if err := repository.AuditRelayReady(request.Context()); err != nil {
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
return
}
writeStatus(writer, http.StatusOK, "ready")
})
server := &http.Server{Addr: cfg.address, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
serverErrors := make(chan error, 1)
go func() {
logger.Info("Bell listening", "address", cfg.address, "version", version, "tls_enabled", cfg.tlsCert != "")
if cfg.tlsCert != "" {
serverErrors <- server.ListenAndServeTLS(cfg.tlsCert, cfg.tlsKey)
return
}
serverErrors <- server.ListenAndServe()
}()
select {
case <-ctx.Done():
case serverErr := <-serverErrors:
if !errors.Is(serverErr, http.ErrServerClosed) {
return fmt.Errorf("serve Bell HTTP: %w", serverErr)
}
}
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return server.Shutdown(shutdownContext)
}
func writeStatus(writer http.ResponseWriter, status int, value string) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(status)
_, _ = fmt.Fprintf(writer, `{"status":%q}`, value)
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"path/filepath"
"testing"
)
func TestConfigurationRequiresDatabaseAndExternalKey(t *testing.T) {
t.Setenv("BELL_DB_DSN", "")
t.Setenv("BELL_AUDIT_KEYS_FILE", "")
if _, err := loadConfiguration(); err == nil {
t.Fatal("missing Bell database was accepted")
}
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
t.Setenv("BELL_AUDIT_KEYS_FILE", "relative.json")
if _, err := loadConfiguration(); err == nil {
t.Fatal("relative Bell key file was accepted")
}
}
func TestConfigurationRequiresTLSOutsideLoopback(t *testing.T) {
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "keys.json"))
t.Setenv("BELL_HTTP_ADDR", "0.0.0.0:8081")
if _, err := loadConfiguration(); err == nil {
t.Fatal("remote plaintext Bell bind was accepted")
}
t.Setenv("BELL_TLS_CERT_FILE", filepath.Join(t.TempDir(), "server.crt"))
t.Setenv("BELL_TLS_KEY_FILE", filepath.Join(t.TempDir(), "server.key"))
if _, err := loadConfiguration(); err != nil {
t.Fatalf("remote TLS Bell bind rejected: %v", err)
}
}
+9
View File
@@ -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
+283
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+363
View File
@@ -0,0 +1,363 @@
// Package audit authenticates and validates Sense audit relay batches.
package audit
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
)
const (
RelayPath = "/internal/v1/audit-events:batch"
MaxBatchSize = 100
MaxBodyBytes = 1 << 20
HeaderKeyID = "X-YoVision-Key-Id"
HeaderTimestamp = "X-YoVision-Timestamp"
HeaderNonce = "X-YoVision-Nonce"
HeaderSignature = "X-YoVision-Signature"
)
var ErrReplayConflict = errors.New("audit relay replay conflict")
type Actor struct {
Type string `json:"type"`
ID string `json:"id"`
}
type ProjectionVersions struct {
QuotaSourceVersion *int64 `json:"quota_source_version"`
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
}
type Event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
TenantID string `json:"tenant_id"`
SiteID string `json:"site_id"`
DeviceID string `json:"device_id"`
Actor Actor `json:"actor"`
Reason *string `json:"reason"`
TraceID *string `json:"trace_id"`
AggregateGeneration int64 `json:"aggregate_generation"`
ProjectionVersions ProjectionVersions `json:"projection_versions"`
Data json.RawMessage `json:"data"`
OccurredAt time.Time `json:"occurred_at"`
}
type Envelope struct {
SchemaVersion int `json:"schema_version"`
Event Event `json:"event"`
}
type Result struct {
EventID string `json:"event_id"`
Status string `json:"status"`
ErrorCode *string `json:"error_code,omitempty"`
}
type BatchResponse struct {
Results []Result `json:"results"`
}
type Candidate struct {
Envelope Envelope
RecordHash [sha256.Size]byte
ErrorCode string
}
type Repository interface {
ProcessAuditBatch(context.Context, string, string, [sha256.Size]byte, []Candidate) ([]Result, error)
}
type Handler struct {
repository Repository
keys map[string][]byte
now func() time.Time
}
func NewHandler(repository Repository, keys map[string][]byte) (*Handler, error) {
if repository == nil || len(keys) == 0 {
return nil, errors.New("audit handler dependencies are required")
}
copyKeys := make(map[string][]byte, len(keys))
for id, secret := range keys {
if !keyIDPattern.MatchString(id) || len(secret) < 32 {
return nil, errors.New("invalid audit handler key")
}
copyKeys[id] = append([]byte(nil), secret...)
}
return &Handler{repository: repository, keys: copyKeys, now: time.Now}, nil
}
func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != RelayPath {
writeError(writer, http.StatusNotFound, "not_found")
return
}
body, err := io.ReadAll(io.LimitReader(request.Body, MaxBodyBytes+1))
if err != nil || len(body) > MaxBodyBytes {
writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large")
return
}
keyID := request.Header.Get(HeaderKeyID)
timestamp := request.Header.Get(HeaderTimestamp)
nonce := request.Header.Get(HeaderNonce)
provided := request.Header.Get(HeaderSignature)
secret, ok := h.keys[keyID]
seconds, timestampErr := strconv.ParseInt(timestamp, 10, 64)
nonceBytes, nonceErr := base64.RawURLEncoding.DecodeString(nonce)
signatureBytes, signatureErr := base64.RawURLEncoding.DecodeString(provided)
if !ok || timestampErr != nil || len(timestamp) < 10 || nonceErr != nil || len(nonceBytes) < 16 || len(nonceBytes) > 48 ||
signatureErr != nil || len(signatureBytes) != sha256.Size || absDuration(h.now().UTC().Sub(time.Unix(seconds, 0).UTC())) > 300*time.Second {
writeError(writer, http.StatusUnauthorized, "unauthorized")
return
}
expected := signature(secret, canonicalString(request.Method, request.URL.EscapedPath(), timestamp, nonce, body))
if !hmac.Equal(signatureBytes, expected) {
writeError(writer, http.StatusUnauthorized, "unauthorized")
return
}
candidates, err := decodeCandidates(body)
if err != nil {
writeError(writer, http.StatusBadRequest, "invalid_batch")
return
}
requestHash := sha256.Sum256(body)
results, err := h.repository.ProcessAuditBatch(request.Context(), keyID, nonce, requestHash, candidates)
if errors.Is(err, ErrReplayConflict) {
writeError(writer, http.StatusConflict, "replay_conflict")
return
}
if err != nil {
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable")
return
}
writeJSON(writer, http.StatusOK, BatchResponse{Results: results})
}
func canonicalString(method, path, timestamp, nonce string, body []byte) string {
digest := sha256.Sum256(body)
return strings.Join([]string{method, path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n")
}
func signature(secret []byte, canonical string) []byte {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(canonical))
return mac.Sum(nil)
}
func absDuration(value time.Duration) time.Duration {
if value < 0 {
return -value
}
return value
}
type rawBatch struct {
Events []json.RawMessage `json:"events"`
}
func decodeCandidates(body []byte) ([]Candidate, error) {
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
var batch rawBatch
if err := decoder.Decode(&batch); err != nil || len(batch.Events) < 1 || len(batch.Events) > MaxBatchSize {
return nil, errors.New("invalid audit batch")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid audit batch trailing data")
}
values := make([]Candidate, len(batch.Events))
for index, raw := range batch.Events {
values[index].RecordHash = sha256.Sum256(raw)
if !hasExactEnvelopeShape(raw) {
values[index].ErrorCode = "schema_invalid"
continue
}
itemDecoder := json.NewDecoder(bytes.NewReader(raw))
itemDecoder.DisallowUnknownFields()
if err := itemDecoder.Decode(&values[index].Envelope); err != nil {
values[index].ErrorCode = "schema_invalid"
continue
}
values[index].ErrorCode = validateEnvelope(values[index].Envelope)
}
return values, nil
}
var (
eventIDPattern = regexp.MustCompile(`^audit_[0-9a-f]{32}$`)
logicalIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
)
func validateEnvelope(value Envelope) string {
event := value.Event
if (value.SchemaVersion != 1 && value.SchemaVersion != 2) || !eventIDPattern.MatchString(event.EventID) ||
!logicalIDPattern.MatchString(event.TenantID) || !logicalIDPattern.MatchString(event.SiteID) || !logicalIDPattern.MatchString(event.DeviceID) ||
event.AggregateGeneration < 1 || event.OccurredAt.IsZero() || strings.TrimSpace(event.Actor.ID) == "" || utf8.RuneCountInString(event.Actor.ID) > 200 ||
(event.Actor.Type != "user" && event.Actor.Type != "service" && event.Actor.Type != "system") ||
(event.Reason != nil && utf8.RuneCountInString(*event.Reason) > 500) || (event.TraceID != nil && utf8.RuneCountInString(*event.TraceID) > 128) ||
(event.ProjectionVersions.QuotaSourceVersion != nil && *event.ProjectionVersions.QuotaSourceVersion < 1) ||
(event.ProjectionVersions.AreaPolicySourceVersion != nil && *event.ProjectionVersions.AreaPolicySourceVersion < 1) {
return "schema_invalid"
}
if event.EventType != "device.created" && event.EventType != "device.desired_state.accepted" &&
(event.EventType != "device.configuration.accepted" || value.SchemaVersion != 2) {
return "schema_invalid"
}
var data map[string]any
if err := json.Unmarshal(event.Data, &data); err != nil || data == nil {
return "schema_invalid"
}
expected := map[string]string{
"device.created": "device_created",
"device.desired_state.accepted": "desired_state_accepted",
"device.configuration.accepted": "configuration_accepted",
}[event.EventType]
if data["kind"] != expected || !validateData(event.EventType, data) || containsSensitiveKey(data) {
return "payload_invalid"
}
return ""
}
func hasExactEnvelopeShape(raw []byte) bool {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(raw, &envelope); err != nil || !exactRawKeys(envelope, "schema_version", "event") {
return false
}
var event map[string]json.RawMessage
if err := json.Unmarshal(envelope["event"], &event); err != nil || !exactRawKeys(event,
"event_id", "event_type", "tenant_id", "site_id", "device_id", "actor", "reason", "trace_id",
"aggregate_generation", "projection_versions", "data", "occurred_at",
) {
return false
}
var actor, projections map[string]json.RawMessage
return json.Unmarshal(event["actor"], &actor) == nil && exactRawKeys(actor, "type", "id") &&
json.Unmarshal(event["projection_versions"], &projections) == nil && exactRawKeys(projections, "quota_source_version", "area_policy_source_version")
}
func exactRawKeys(value map[string]json.RawMessage, expected ...string) bool {
if len(value) != len(expected) {
return false
}
for _, key := range expected {
if _, exists := value[key]; !exists {
return false
}
}
return true
}
func validateData(eventType string, data map[string]any) bool {
switch eventType {
case "device.created":
if !exactAnyKeys(data, "kind", "area_id", "modality", "capabilities", "desired_state") || !logicalIDPattern.MatchString(stringValue(data["area_id"])) {
return false
}
if !member(stringValue(data["modality"]), "video", "radar", "contact", "button", "wearable", "other") || !member(stringValue(data["desired_state"]), "disabled", "enabled") {
return false
}
return validStringSet(data["capabilities"], 16, "video_capture", "audio_capture", "spatial_rule", "telemetry")
case "device.desired_state.accepted":
if !exactAnyKeys(data, "kind", "previous_desired_state", "desired_state", "changed") {
return false
}
_, changed := data["changed"].(bool)
return changed && member(stringValue(data["previous_desired_state"]), "disabled", "enabled") && member(stringValue(data["desired_state"]), "disabled", "enabled")
case "device.configuration.accepted":
if !exactAnyKeys(data, "kind", "changed", "changed_fields", "area_id") || !logicalIDPattern.MatchString(stringValue(data["area_id"])) {
return false
}
_, changed := data["changed"].(bool)
return changed && validStringSet(data["changed_fields"], 5, "name", "area_id", "endpoint_ref", "credential_ref", "profile_token")
default:
return false
}
}
func exactAnyKeys(value map[string]any, expected ...string) bool {
if len(value) != len(expected) {
return false
}
for _, key := range expected {
if _, exists := value[key]; !exists {
return false
}
}
return true
}
func stringValue(value any) string {
result, _ := value.(string)
return result
}
func member(value string, allowed ...string) bool {
for _, candidate := range allowed {
if value == candidate {
return true
}
}
return false
}
func validStringSet(value any, maximum int, allowed ...string) bool {
items, ok := value.([]any)
if !ok || len(items) > maximum {
return false
}
seen := make(map[string]bool, len(items))
for _, item := range items {
text, ok := item.(string)
if !ok || !member(text, allowed...) || seen[text] {
return false
}
seen[text] = true
}
return true
}
func containsSensitiveKey(value any) bool {
forbidden := map[string]bool{"password": true, "stream_uri": true, "mediamtx_config": true}
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
if forbidden[strings.ToLower(key)] || containsSensitiveKey(child) {
return true
}
}
case []any:
for _, child := range typed {
if containsSensitiveKey(child) {
return true
}
}
}
return false
}
func writeError(writer http.ResponseWriter, status int, code string) {
writeJSON(writer, status, map[string]string{"error": code})
}
func writeJSON(writer http.ResponseWriter, status int, value any) {
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Cache-Control", "no-store")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
+105
View File
@@ -0,0 +1,105 @@
package audit
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
)
type recordingRepository struct {
candidates []Candidate
results []Result
err error
}
func (r *recordingRepository) ProcessAuditBatch(_ context.Context, _, _ string, _ [sha256.Size]byte, values []Candidate) ([]Result, error) {
r.candidates = values
return r.results, r.err
}
func validBody(t *testing.T) []byte {
t.Helper()
value := map[string]any{"events": []any{map[string]any{
"schema_version": 1,
"event": map[string]any{
"event_id": "audit_00000000000000000000000000000001", "event_type": "device.created",
"tenant_id": "tenant", "site_id": "site", "device_id": "camera-1",
"actor": map[string]any{"type": "system", "id": "sense"}, "reason": nil, "trace_id": nil,
"aggregate_generation": 1,
"projection_versions": map[string]any{"quota_source_version": 1, "area_policy_source_version": 1},
"data": map[string]any{"kind": "device_created", "area_id": "area", "modality": "video", "capabilities": []any{"video_capture"}, "desired_state": "enabled"},
"occurred_at": "2026-08-11T00:00:00Z",
},
}}}
raw, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return raw
}
func signedRequest(t *testing.T, body, secret []byte, timestamp time.Time, nonce string) *http.Request {
t.Helper()
request := httptest.NewRequest(http.MethodPost, RelayPath, bytes.NewReader(body))
stamp := strconv.FormatInt(timestamp.Unix(), 10)
request.Header.Set(HeaderKeyID, "sense-a")
request.Header.Set(HeaderTimestamp, stamp)
request.Header.Set(HeaderNonce, nonce)
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(signature(secret, canonicalString(http.MethodPost, RelayPath, stamp, nonce, body))))
return request
}
func TestHandlerAuthenticatesAndReturnsPerItemResults(t *testing.T) {
secret := bytes.Repeat([]byte{3}, 32)
repository := &recordingRepository{results: []Result{{EventID: "audit_00000000000000000000000000000001", Status: "accepted"}}}
handler, err := NewHandler(repository, map[string][]byte{"sense-a": secret})
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 11, 0, 1, 0, 0, time.UTC)
handler.now = func() time.Time { return now }
response := httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, validBody(t), secret, now, "AAAAAAAAAAAAAAAAAAAAAA"))
if response.Code != http.StatusOK || len(repository.candidates) != 1 || repository.candidates[0].ErrorCode != "" {
t.Fatalf("valid batch rejected: status=%d candidates=%+v", response.Code, repository.candidates)
}
}
func TestHandlerRejectsStaleOrTamperedRequests(t *testing.T) {
secret := bytes.Repeat([]byte{4}, 32)
repository := &recordingRepository{}
handler, _ := NewHandler(repository, map[string][]byte{"sense-a": secret})
now := time.Date(2026, 8, 11, 0, 10, 0, 0, time.UTC)
handler.now = func() time.Time { return now }
for _, request := range []*http.Request{
signedRequest(t, validBody(t), secret, now.Add(-301*time.Second), "BBBBBBBBBBBBBBBBBBBBBB"),
signedRequest(t, append(validBody(t), ' '), bytes.Repeat([]byte{5}, 32), now, "CCCCCCCCCCCCCCCCCCCCCC"),
} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("unsafe request returned %d", response.Code)
}
}
}
func TestDecodeCandidatesRejectsSensitiveItemWithoutRejectingBatch(t *testing.T) {
body := validBody(t)
var value map[string]any
_ = json.Unmarshal(body, &value)
events := value["events"].([]any)
event := events[0].(map[string]any)["event"].(map[string]any)
event["data"].(map[string]any)["password"] = "must-not-persist"
body, _ = json.Marshal(value)
candidates, err := decodeCandidates(body)
if err != nil || len(candidates) != 1 || candidates[0].ErrorCode != "payload_invalid" {
t.Fatalf("unexpected per-item validation: %+v %v", candidates, err)
}
}
+47
View File
@@ -0,0 +1,47 @@
package audit
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
)
type keyDocument struct {
Version int `json:"version"`
Keys []struct {
KeyID string `json:"key_id"`
Secret string `json:"secret_base64url"`
} `json:"keys"`
}
func LoadKeys(path string) (map[string][]byte, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read Bell audit key file")
}
var document keyDocument
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil || document.Version != 1 || len(document.Keys) == 0 {
return nil, errors.New("invalid Bell audit key file")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid Bell audit key file")
}
values := make(map[string][]byte, len(document.Keys))
for _, item := range document.Keys {
secret, err := base64.RawURLEncoding.DecodeString(item.Secret)
if err != nil || !keyIDPattern.MatchString(item.KeyID) || len(secret) < 32 {
return nil, errors.New("invalid Bell audit key")
}
if _, exists := values[item.KeyID]; exists {
return nil, errors.New("duplicate Bell audit key ID")
}
values[item.KeyID] = secret
}
return values, nil
}
+302
View File
@@ -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
}
+184
View File
@@ -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)
}
+138
View File
@@ -0,0 +1,138 @@
package store
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"yovision/bell/internal/audit"
)
func (p *Postgres) AuditRelayReady(ctx context.Context) error {
var version int64
if err := p.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 4 {
return errors.New("postgres Bell schema migration v4 is required for audit relay")
}
var auditSelect, auditInsert, auditUpdate, auditDelete, auditTruncate bool
var receiptUse bool
if err := p.db.QueryRowContext(ctx, `SELECT
has_table_privilege(current_user, 'bell.audit_events', 'SELECT'),
has_table_privilege(current_user, 'bell.audit_events', 'INSERT'),
has_table_privilege(current_user, 'bell.audit_events', 'UPDATE'),
has_table_privilege(current_user, 'bell.audit_events', 'DELETE'),
has_table_privilege(current_user, 'bell.audit_events', 'TRUNCATE'),
has_table_privilege(current_user, 'bell.audit_relay_receipts', 'SELECT,INSERT,DELETE')`).Scan(
&auditSelect, &auditInsert, &auditUpdate, &auditDelete, &auditTruncate, &receiptUse,
); err != nil {
return errors.New("verify Bell audit relay privileges")
}
if !auditSelect || !auditInsert || auditUpdate || auditDelete || auditTruncate || !receiptUse {
return errors.New("Bell audit relay privileges violate append-only boundary")
}
return nil
}
func (p *Postgres) ProcessAuditBatch(
ctx context.Context,
keyID, nonce string,
requestHash [sha256.Size]byte,
candidates []audit.Candidate,
) ([]audit.Result, error) {
if len(candidates) < 1 || len(candidates) > audit.MaxBatchSize {
return nil, errors.New("invalid audit candidate batch")
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return nil, errors.New("begin Bell audit batch")
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, keyID, nonce); err != nil {
return nil, errors.New("lock Bell audit receipt")
}
if _, err := tx.ExecContext(ctx, `DELETE FROM bell.audit_relay_receipts WHERE expires_at <= clock_timestamp()`); err != nil {
return nil, errors.New("expire Bell audit receipts")
}
var existingHash, existingBody []byte
err = tx.QueryRowContext(ctx, `SELECT request_hash, response_body::text
FROM bell.audit_relay_receipts WHERE key_id=$1 AND nonce=$2`, keyID, nonce).Scan(&existingHash, &existingBody)
if err == nil {
if !bytes.Equal(existingHash, requestHash[:]) {
return nil, audit.ErrReplayConflict
}
var response audit.BatchResponse
if err := json.Unmarshal(existingBody, &response); err != nil {
return nil, errors.New("decode stored Bell audit receipt")
}
if err := tx.Commit(); err != nil {
return nil, errors.New("commit Bell audit replay")
}
return response.Results, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("read Bell audit receipt")
}
results := make([]audit.Result, 0, len(candidates))
for _, candidate := range candidates {
if candidate.ErrorCode != "" {
code := candidate.ErrorCode
results = append(results, audit.Result{EventID: candidate.Envelope.Event.EventID, Status: "rejected", ErrorCode: &code})
continue
}
event := candidate.Envelope.Event
payload, err := json.Marshal(event)
if err != nil {
return nil, errors.New("encode Bell audit fact")
}
result, err := tx.ExecContext(ctx, `INSERT INTO bell.audit_events(
source_system, event_id, schema_version, 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, record_hash
) VALUES ('sense',$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16)
ON CONFLICT (source_system,event_id) DO NOTHING`,
event.EventID, candidate.Envelope.SchemaVersion, event.EventType, event.TenantID,
event.SiteID, event.DeviceID, event.Actor.Type, event.Actor.ID, event.Reason,
event.TraceID, event.AggregateGeneration,
event.ProjectionVersions.QuotaSourceVersion,
event.ProjectionVersions.AreaPolicySourceVersion,
payload, event.OccurredAt, candidate.RecordHash[:])
if err != nil {
return nil, fmt.Errorf("insert Bell audit fact: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return nil, errors.New("read Bell audit insert result")
}
if affected == 1 {
results = append(results, audit.Result{EventID: event.EventID, Status: "accepted"})
continue
}
var storedHash []byte
if err := tx.QueryRowContext(ctx, `SELECT record_hash FROM bell.audit_events
WHERE source_system='sense' AND event_id=$1`, event.EventID).Scan(&storedHash); err != nil {
return nil, errors.New("read existing Bell audit fact")
}
if bytes.Equal(storedHash, candidate.RecordHash[:]) {
results = append(results, audit.Result{EventID: event.EventID, Status: "duplicate"})
} else {
code := "id_conflict"
results = append(results, audit.Result{EventID: event.EventID, Status: "rejected", ErrorCode: &code})
}
}
encoded, err := json.Marshal(audit.BatchResponse{Results: results})
if err != nil {
return nil, errors.New("encode Bell audit response")
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.audit_relay_receipts(
key_id, nonce, request_hash, response_status, response_body, expires_at
) VALUES ($1,$2,$3,200,$4::jsonb,clock_timestamp() + interval '10 minutes')`, keyID, nonce, requestHash[:], encoded); err != nil {
return nil, errors.New("insert Bell audit receipt")
}
if err := tx.Commit(); err != nil {
return nil, errors.New("commit Bell audit batch")
}
return results, nil
}
@@ -0,0 +1,82 @@
package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"os"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"yovision/bell/internal/audit"
)
func auditCandidate(t *testing.T, eventID, actorID string) audit.Candidate {
t.Helper()
data := json.RawMessage(`{"kind":"device_created","area_id":"area","modality":"video","capabilities":["video_capture"],"desired_state":"enabled"}`)
value := audit.Envelope{SchemaVersion: 1, Event: audit.Event{
EventID: eventID, EventType: "device.created", TenantID: "tenant", SiteID: "site", DeviceID: "camera-1",
Actor: audit.Actor{Type: "system", ID: actorID}, AggregateGeneration: 1,
ProjectionVersions: audit.ProjectionVersions{}, Data: data, OccurredAt: time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC),
}}
raw, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return audit.Candidate{Envelope: value, RecordHash: sha256.Sum256(raw)}
}
func TestPostgresAuditBatchReceiptAndImmutableFact(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()
repository, err := OpenPostgres(ctx, db)
if err != nil {
t.Fatal(err)
}
if err := repository.AuditRelayReady(ctx); err != nil {
t.Fatal(err)
}
requestHash := sha256.Sum256([]byte("request-one"))
eventID := "audit_10000000000000000000000000000001"
results, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, []audit.Candidate{auditCandidate(t, eventID, "sense")})
if err != nil || len(results) != 1 || results[0].Status != "accepted" {
t.Fatalf("first batch: %+v %v", results, err)
}
replayed, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, []audit.Candidate{auditCandidate(t, eventID, "ignored-by-receipt")})
if err != nil || replayed[0].Status != "accepted" {
t.Fatalf("receipt replay: %+v %v", replayed, err)
}
different := sha256.Sum256([]byte("request-two"))
if _, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", different, []audit.Candidate{auditCandidate(t, eventID, "sense")}); !errors.Is(err, audit.ErrReplayConflict) {
t.Fatalf("expected replay conflict, got %v", err)
}
duplicate, err := repository.ProcessAuditBatch(ctx, "sense-a", "BBBBBBBBBBBBBBBBBBBBBB", different, []audit.Candidate{auditCandidate(t, eventID, "sense")})
if err != nil || duplicate[0].Status != "duplicate" {
t.Fatalf("event duplicate: %+v %v", duplicate, err)
}
conflictHash := sha256.Sum256([]byte("request-three"))
conflict, err := repository.ProcessAuditBatch(ctx, "sense-a", "CCCCCCCCCCCCCCCCCCCCCC", conflictHash, []audit.Candidate{auditCandidate(t, eventID, "other")})
if err != nil || conflict[0].Status != "rejected" || conflict[0].ErrorCode == nil || *conflict[0].ErrorCode != "id_conflict" {
t.Fatalf("event conflict: %+v %v", conflict, err)
}
if _, err := db.ExecContext(ctx, `UPDATE bell.audit_events SET actor_id='mutated' WHERE event_id=$1`, eventID); err == nil {
t.Fatal("runtime updated immutable audit fact")
}
if _, err := db.ExecContext(ctx, `DELETE FROM bell.audit_events WHERE event_id=$1`, eventID); err == nil {
t.Fatal("runtime deleted immutable audit fact")
}
}
+149
View File
@@ -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
}
+120
View File
@@ -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")
}
}
+77 -6
View File
@@ -1,20 +1,20 @@
# Sense M1/M2 接入骨架 # Sense M1/M2 接入骨架
本目录是 YoVision Sense 的 M1/M2 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009 增加 PostgreSQL 双 schema 生产路径。默认关闭真实 ONVIF,显式设置 `SENSE_ONVIF_MODE=standard` 后才启用标准 SOAP/WS-Security 适配器。T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。 本目录是 YoVision Sense 的 M1/M2 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-016 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox、Control API v1、多实例调和 fencing、孤儿受控处置和到 Bell 的审计 relay。默认关闭真实 ONVIF、公共业务路由和 relay;T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。
## 常用命令 ## 常用命令
```powershell ```powershell
cd Sense cd Sense
go mod download go mod download
go generate ./internal/mtx go generate ./internal/mtx ./internal/controlapi
go test ./... go test ./...
go vet ./... go vet ./...
go build -o bin/sense-api.exe ./cmd/sense-api go build -o bin/sense-api.exe ./cmd/sense-api
go run ./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`;Sense Control API v1 虽已冻结,但 HTTP handler 和认证尚未实现。 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 路由。
常用环境变量: 常用环境变量:
@@ -26,11 +26,27 @@ Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`
| `SENSE_DB_DSN` | `file:data/sense.db` | 所选 driver 的私有 DSN;不得写入日志、文档或仓库,PostgreSQL 密码优先由 `PGPASSFILE`/环境密钥提供 | | `SENSE_DB_DSN` | `file:data/sense.db` | 所选 driver 的私有 DSN;不得写入日志、文档或仓库,PostgreSQL 密码优先由 `PGPASSFILE`/环境密钥提供 |
| `SENSE_MEDIAMTX_URL` | `http://127.0.0.1:9997` | MediaMTX 控制 API;不得包含 userinfo | | `SENSE_MEDIAMTX_URL` | `http://127.0.0.1:9997` | MediaMTX 控制 API;不得包含 userinfo |
| `SENSE_RECONCILE_INTERVAL` | `5s` | 对账周期 | | `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_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_MODE` | `disabled` | `disabled` 或 `standard`;默认不访问真实摄像头 |
| `SENSE_ONVIF_RTSP_REWRITE_HOST` | 空 | NAT 或故障代理场景下重写 ONVIF 返回的 RTSP 主机 | | `SENSE_ONVIF_RTSP_REWRITE_HOST` | 空 | NAT 或故障代理场景下重写 ONVIF 返回的 RTSP 主机 |
| `SENSE_ONVIF_RTSP_REWRITE_PORT` | `0` | 非零时重写 ONVIF 返回的 RTSP 端口 | | `SENSE_ONVIF_RTSP_REWRITE_PORT` | `0` | 非零时重写 ONVIF 返回的 RTSP 端口 |
| `SENSE_ONVIF_RTSP_STRIP_QUERY` | `false` | 仅在已验证设备返回不可用查询串时显式移除;默认保留标准 URI 语义 | | `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 |
| `SENSE_AUDIT_RELAY_ENABLED` | `false` | 显式开启 PostgreSQL Outbox → Bell relay;SQLite 不支持 |
| `SENSE_AUDIT_RELAY_URL` | 空 | 精确指向 Bell `/internal/v1/audit-events:batch`;非回环必须 HTTPS |
| `SENSE_AUDIT_RELAY_KEY_FILE` | 空 | 仓库外绝对路径 version 1 JSON key 文件,secret 至少 32 字节 |
| `SENSE_AUDIT_RELAY_KEY_ID` | 空 | 本实例用于签名的 key ID |
| `SENSE_AUDIT_RELAY_INTERVAL` | `1s` | 队列轮询间隔,最短 1 秒 |
设备台账只保存 `env://<key>` 凭据引用。真实适配器从进程环境读取以下变量,不把秘密写入 SQLite、日志或 MediaMTX 错误: 设备台账只保存 `env://<key>` 凭据引用。真实适配器从进程环境读取以下变量,不把秘密写入 SQLite、日志或 MediaMTX 错误:
@@ -45,9 +61,11 @@ SENSE_CREDENTIAL_<KEY>_RTSP_PASSWORD
MediaMTX `v1.19.3` 应作为独立二进制启动并只在可信网络开放 API。获取与 SHA-256 校验值见 `docs/03-tech-stack.md`。生成客户端使用固定版本工具和 vendored 官方 OpenAPI;`internal/mtx/generated/client.gen.go` 不可手改。 MediaMTX `v1.19.3` 应作为独立二进制启动并只在可信网络开放 API。获取与 SHA-256 校验值见 `docs/03-tech-stack.md`。生成客户端使用固定版本工具和 vendored 官方 OpenAPI;`internal/mtx/generated/client.gen.go` 不可手改。
## T-009 PostgreSQL 17.10 ## T-009~T-012 PostgreSQL 17.10
初始化 SQL 位于 `deploy/postgres/`,由高权限部署步骤按文件名前缀执行;Sense 进程不会自动创建角色、schema 或 Bell 对象。`bell_app` 拥有 Site/配额和 `bell.site_quota_v1`,`sense_app` 只能读取该视图,不能读取或写入 Bell 源表。应用登录角色和密码由部署环境创建,不进入仓库。 初始化与增量 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。开启 relay 后,Sense 用数据库 lease/fencing 批量投递,成功、重试和 dead letter 均保留本地事实;Sense 不访问 Bell schema。
Windows 本机集成测试从仓库根目录执行: Windows 本机集成测试从仓库根目录执行:
@@ -65,7 +83,44 @@ $env:SENSE_DB_DSN = '由部署环境私下设置'
go run ./cmd/sense-api go run ./cmd/sense-api
``` ```
PostgreSQL 启动会检查 Sense migration 版本及当前角色的跨 schema 权限;权限过宽、配额视图不可读或 schema 未安装时 readiness 初始化失败。默认 SQLite 路径和 `cmd/sense-lab` 保持不变,T-009 不搬迁现有 SQLite 数据。 PostgreSQL 基础启动会检查 Sense v5 migration、当前角色对两个 Bell 投影视图和本地控制/对账表的最小权限;启用 relay 时额外要求 v6 和本地 Outbox 权限,并确认当前 Sense 登录不能访问 Bell 全局审计表。默认 SQLite 路径和 `cmd/sense-lab` 保持不变,但 SQLite 不实现生产 Area/Outbox、多实例租约、孤儿处置或 relay 语义,Control API、孤儿扫描与 relay 在 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(从仓库根目录执行): Windows 本地准备 MediaMTX(从仓库根目录执行):
@@ -88,3 +143,19 @@ Linux amd64 使用同版 `mediamtx_v1.19.3_linux_amd64.tar.gz`,SHA-256 为 `a7
``` ```
`ip_camera.env` 必须保持在 Git 忽略范围内。调试时可把 `-ObservationMinutes` 降为 1;正式 T-006 证据必须使用默认 30 分钟,且最终 `maximum_unconverged`、`final_unconverged` 都为 0。 `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。
+13
View File
@@ -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"]
}
]
}
+88 -11
View File
@@ -12,9 +12,14 @@ import (
"syscall" "syscall"
"time" "time"
"yovision/sense/internal/auditrelay"
"yovision/sense/internal/auth"
"yovision/sense/internal/config" "yovision/sense/internal/config"
"yovision/sense/internal/controlapi"
"yovision/sense/internal/metrics"
"yovision/sense/internal/mtx" "yovision/sense/internal/mtx"
"yovision/sense/internal/onvif" "yovision/sense/internal/onvif"
"yovision/sense/internal/orphan"
"yovision/sense/internal/probe" "yovision/sense/internal/probe"
"yovision/sense/internal/reconcile" "yovision/sense/internal/reconcile"
"yovision/sense/internal/store" "yovision/sense/internal/store"
@@ -35,6 +40,14 @@ func run(logger *slog.Logger) error {
if err != nil { if err != nil {
return fmt.Errorf("load configuration: %w", err) 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) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
@@ -43,6 +56,44 @@ func run(logger *slog.Logger) error {
return err return err
} }
defer repository.Close() defer repository.Close()
var auditWorker *auditrelay.Worker
if cfg.AuditRelayEnabled {
relayStore, ok := repository.(auditrelay.Repository)
if !ok {
return errors.New("selected repository does not support audit relay")
}
if err := relayStore.AuditRelayReady(ctx); err != nil {
return err
}
secret, err := auditrelay.LoadKey(cfg.AuditRelayKeyFile, cfg.AuditRelayKeyID)
if err != nil {
return err
}
client, err := auditrelay.NewClient(cfg.AuditRelayURL, cfg.AuditRelayKeyID, secret, nil)
if err != nil {
return err
}
auditWorker, err = auditrelay.NewWorker(relayStore, client, instanceID)
if err != nil {
return err
}
}
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) mediaClient, err := mtx.NewClient(cfg.MediaMTXURL, nil)
if err != nil { if err != nil {
return err return err
@@ -58,22 +109,39 @@ func run(logger *slog.Logger) error {
}) })
} }
discovery := onvif.NewRouter(cameraAdapter, credentials) discovery := onvif.NewRouter(cameraAdapter, credentials)
reconciler := reconcile.New(repository, discovery, mediaClient) reconciler := reconcile.NewWithOptions(repository, discovery, mediaClient, reconcile.Options{
InstanceID: instanceID, LeaseDuration: cfg.ReconcileLeaseDuration,
OperationTimeout: cfg.ReconcileOperationTimeout, Metrics: registry,
})
checker := probe.New(repository, mediaClient) 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) { report := func(err error) {
// Domain and MediaMTX errors intentionally omit stream URIs and credentials. // Domain and MediaMTX errors intentionally omit stream URIs and credentials.
logger.Warn("background convergence error", "error", err) logger.Warn("background convergence error", "error", err)
} }
var background sync.WaitGroup var background sync.WaitGroup
background.Add(2) startBackground := func(run func()) {
go func() { background.Add(1)
defer background.Done() go func() {
reconciler.Run(ctx, cfg.ReconcileInterval, report) defer background.Done()
}() run()
go func() { }()
defer background.Done() }
checker.Run(ctx, cfg.ProbeInterval, report) 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) })
}
if auditWorker != nil {
startBackground(func() { auditWorker.Run(ctx, cfg.AuditRelayInterval, report) })
}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) { mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
@@ -86,6 +154,12 @@ func run(logger *slog.Logger) error {
writer.WriteHeader(http.StatusOK) writer.WriteHeader(http.StatusOK)
_, _ = writer.Write([]byte(`{"status":"ready"}`)) _, _ = 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{ server := &http.Server{
Addr: cfg.HTTPAddress, Handler: mux, Addr: cfg.HTTPAddress, Handler: mux,
@@ -96,7 +170,10 @@ func run(logger *slog.Logger) error {
} }
serverErrors := make(chan error, 1) serverErrors := make(chan error, 1)
go func() { go func() {
logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version) logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version,
"instance_id", instanceID,
"control_api_enabled", cfg.ControlAPIEnabled,
"audit_relay_enabled", cfg.AuditRelayEnabled)
serverErrors <- server.ListenAndServe() serverErrors <- server.ListenAndServe()
}() }()
+94
View File
@@ -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,132 @@
package auditrelay
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func testEnvelope(id string) Envelope {
return Envelope{SchemaVersion: 1, Event: Event{
EventID: id, EventType: "device.created", TenantID: "tenant", SiteID: "site", DeviceID: "camera-1",
Actor: Actor{Type: "system", ID: "sense"}, AggregateGeneration: 1,
ProjectionVersions: ProjectionVersions{}, Data: json.RawMessage(`{"kind":"device_created"}`), OccurredAt: time.Unix(1, 0).UTC(),
}}
}
func TestClientSignsCanonicalRequest(t *testing.T) {
secret := bytesOf(32, 7)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
body := make([]byte, request.ContentLength)
_, _ = request.Body.Read(body)
canonical := CanonicalString(request.Method, request.URL.Path, request.Header.Get(HeaderTimestamp), request.Header.Get(HeaderNonce), body)
if request.Header.Get(HeaderSignature) != Signature(secret, canonical) {
t.Error("request signature did not match canonical vector")
}
_ = json.NewEncoder(writer).Encode(BatchResponse{Results: []Result{{EventID: "audit_00000000000000000000000000000001", Status: "accepted"}}})
}))
defer server.Close()
client, err := NewClient(server.URL+RelayPath, "sense-a", secret, server.Client())
if err != nil {
t.Fatal(err)
}
client.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
client.nonce = func() (string, error) { return "AAAAAAAAAAAAAAAAAAAAAA", nil }
results, err := client.Send(context.Background(), []Envelope{testEnvelope("audit_00000000000000000000000000000001")})
if err != nil || len(results) != 1 || results[0].Status != "accepted" {
t.Fatalf("unexpected relay result: %+v, %v", results, err)
}
}
func TestEndpointAndKeySecurity(t *testing.T) {
if _, err := ValidateEndpoint("http://example.com" + RelayPath); err == nil {
t.Fatal("remote plaintext relay URL was accepted")
}
if _, err := ValidateEndpoint("https://example.com" + RelayPath + "?secret=x"); err == nil {
t.Fatal("relay URL query was accepted")
}
secret := bytesOf(32, 9)
path := filepath.Join(t.TempDir(), "keys.json")
document := map[string]any{"version": 1, "keys": []any{map[string]any{"key_id": "sense-a", "secret_base64url": base64.RawURLEncoding.EncodeToString(secret)}}}
raw, _ := json.Marshal(document)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
loaded, err := LoadKey(path, "sense-a")
if err != nil || sha256.Sum256(loaded) != sha256.Sum256(secret) {
t.Fatalf("external key was not loaded: %v", err)
}
}
type fakeRepository struct {
queued []QueuedEvent
completed []Completion
}
func (*fakeRepository) AuditRelayReady(context.Context) error { return nil }
func (f *fakeRepository) ClaimAuditRelayBatch(context.Context, string, int, time.Duration) ([]QueuedEvent, error) {
return f.queued, nil
}
func (f *fakeRepository) CompleteAuditRelayBatch(_ context.Context, _ string, values []Completion) error {
f.completed = append([]Completion(nil), values...)
return nil
}
type fakeSender struct {
results []Result
err error
}
func (f fakeSender) Send(context.Context, []Envelope) ([]Result, error) { return f.results, f.err }
func TestWorkerDispositionAndBackoff(t *testing.T) {
first := "audit_00000000000000000000000000000001"
second := "audit_00000000000000000000000000000002"
repository := &fakeRepository{queued: []QueuedEvent{{Envelope: testEnvelope(first), LeaseToken: 1, AttemptCount: 1}, {Envelope: testEnvelope(second), LeaseToken: 2, AttemptCount: 10}}}
code := "schema_invalid"
worker, _ := NewWorker(repository, fakeSender{results: []Result{{EventID: first, Status: "accepted"}, {EventID: second, Status: "rejected", ErrorCode: &code}}}, "worker")
if err := worker.RelayOnce(context.Background()); err != nil {
t.Fatal(err)
}
if repository.completed[0].Disposition != Delivered || repository.completed[1].Disposition != DeadLetter {
t.Fatalf("unexpected dispositions: %+v", repository.completed)
}
repository.completed = nil
worker.sender = fakeSender{err: errors.New("network")}
if err := worker.RelayOnce(context.Background()); err == nil {
t.Fatal("network failure was hidden")
}
if repository.completed[0].RetryAfter != time.Second || repository.completed[1].RetryAfter != 300*time.Second {
t.Fatalf("retry bounds drifted: %+v", repository.completed)
}
}
func TestWorkerRetriesWholeBatchForIncompleteResponse(t *testing.T) {
first := "audit_00000000000000000000000000000001"
second := "audit_00000000000000000000000000000002"
repository := &fakeRepository{queued: []QueuedEvent{{Envelope: testEnvelope(first), LeaseToken: 1, AttemptCount: 1}, {Envelope: testEnvelope(second), LeaseToken: 2, AttemptCount: 2}}}
worker, _ := NewWorker(repository, fakeSender{results: []Result{{EventID: first, Status: "accepted"}}}, "worker")
if err := worker.RelayOnce(context.Background()); err == nil {
t.Fatal("incomplete response was accepted")
}
if len(repository.completed) != 2 || repository.completed[0].Disposition != Retry || repository.completed[1].Disposition != Retry {
t.Fatalf("incomplete response partially completed the batch: %+v", repository.completed)
}
}
func bytesOf(size int, value byte) []byte {
result := make([]byte, size)
for index := range result {
result[index] = value
}
return result
}
+131
View File
@@ -0,0 +1,131 @@
package auditrelay
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
HeaderKeyID = "X-YoVision-Key-Id"
HeaderTimestamp = "X-YoVision-Timestamp"
HeaderNonce = "X-YoVision-Nonce"
HeaderSignature = "X-YoVision-Signature"
RelayPath = "/internal/v1/audit-events:batch"
)
type Client struct {
endpoint *url.URL
keyID string
secret []byte
httpClient *http.Client
now func() time.Time
nonce func() (string, error)
}
func NewClient(rawURL, keyID string, secret []byte, client *http.Client) (*Client, error) {
endpoint, err := ValidateEndpoint(rawURL)
if err != nil {
return nil, err
}
if keyID == "" || len(secret) < 32 {
return nil, errors.New("audit relay key ID and 32-byte secret are required")
}
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
return &Client{endpoint: endpoint, keyID: keyID, secret: append([]byte(nil), secret...), httpClient: client, now: time.Now, nonce: randomNonce}, nil
}
func ValidateEndpoint(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" || parsed.Path != RelayPath || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
return nil, errors.New("invalid Bell audit relay URL")
}
host := parsed.Hostname()
ip := net.ParseIP(host)
loopback := strings.EqualFold(host, "localhost") || (ip != nil && ip.IsLoopback())
if parsed.Scheme != "https" && !(parsed.Scheme == "http" && loopback) {
return nil, errors.New("Bell audit relay URL requires HTTPS outside loopback")
}
return parsed, nil
}
func CanonicalString(method, path, timestamp, nonce string, body []byte) string {
digest := sha256.Sum256(body)
return strings.Join([]string{method, path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n")
}
func Signature(secret []byte, canonical string) string {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(canonical))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func randomNonce() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func (c *Client) Send(ctx context.Context, events []Envelope) ([]Result, error) {
if len(events) < 1 || len(events) > MaxBatchSize {
return nil, errors.New("audit relay batch must contain 1 to 100 events")
}
body, err := json.Marshal(BatchRequest{Events: events})
if err != nil || len(body) > MaxBodyBytes {
return nil, errors.New("encode audit relay batch")
}
requestContext, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
timestamp := strconv.FormatInt(c.now().UTC().Unix(), 10)
nonce, err := c.nonce()
if err != nil {
return nil, errors.New("generate audit relay nonce")
}
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, c.endpoint.String(), bytes.NewReader(body))
if err != nil {
return nil, errors.New("create audit relay request")
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set(HeaderKeyID, c.keyID)
request.Header.Set(HeaderTimestamp, timestamp)
request.Header.Set(HeaderNonce, nonce)
request.Header.Set(HeaderSignature, Signature(c.secret, CanonicalString(http.MethodPost, RelayPath, timestamp, nonce, body)))
response, err := c.httpClient.Do(request)
if err != nil {
return nil, errors.New("send audit relay request")
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
return nil, fmt.Errorf("Bell audit relay returned HTTP %d", response.StatusCode)
}
decoder := json.NewDecoder(io.LimitReader(response.Body, MaxBodyBytes+1))
decoder.DisallowUnknownFields()
var decoded BatchResponse
if err := decoder.Decode(&decoded); err != nil {
return nil, errors.New("decode audit relay response")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("audit relay response contains trailing data")
}
return decoded.Results, nil
}
+53
View File
@@ -0,0 +1,53 @@
package auditrelay
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"regexp"
)
var keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
type keyFile struct {
Version int `json:"version"`
Keys []struct {
KeyID string `json:"key_id"`
Secret string `json:"secret_base64url"`
} `json:"keys"`
}
func LoadKey(path, keyID string) ([]byte, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read audit relay key file")
}
var document keyFile
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil || document.Version != 1 || len(document.Keys) == 0 {
return nil, errors.New("invalid audit relay key file")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid audit relay key file")
}
values := make(map[string][]byte, len(document.Keys))
for _, candidate := range document.Keys {
secret, err := base64.RawURLEncoding.DecodeString(candidate.Secret)
if err != nil || !keyIDPattern.MatchString(candidate.KeyID) || len(secret) < 32 {
return nil, errors.New("invalid audit relay secret")
}
if _, duplicate := values[candidate.KeyID]; duplicate {
return nil, errors.New("duplicate audit relay key ID")
}
values[candidate.KeyID] = secret
}
if secret, exists := values[keyID]; exists {
return secret, nil
}
return nil, errors.New("audit relay key ID not found")
}
+89
View File
@@ -0,0 +1,89 @@
// Package auditrelay delivers Sense-owned audit facts to Bell without sharing databases.
package auditrelay
import (
"context"
"encoding/json"
"errors"
"time"
)
const (
MaxBatchSize = 100
MaxBodyBytes = 1 << 20
LeaseDuration = 30 * time.Second
)
type Actor struct {
Type string `json:"type"`
ID string `json:"id"`
}
type ProjectionVersions struct {
QuotaSourceVersion *int64 `json:"quota_source_version"`
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
}
type Event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
TenantID string `json:"tenant_id"`
SiteID string `json:"site_id"`
DeviceID string `json:"device_id"`
Actor Actor `json:"actor"`
Reason *string `json:"reason"`
TraceID *string `json:"trace_id"`
AggregateGeneration int64 `json:"aggregate_generation"`
ProjectionVersions ProjectionVersions `json:"projection_versions"`
Data json.RawMessage `json:"data"`
OccurredAt time.Time `json:"occurred_at"`
}
type Envelope struct {
SchemaVersion int `json:"schema_version"`
Event Event `json:"event"`
}
type BatchRequest struct {
Events []Envelope `json:"events"`
}
type Result struct {
EventID string `json:"event_id"`
Status string `json:"status"`
ErrorCode *string `json:"error_code,omitempty"`
}
type BatchResponse struct {
Results []Result `json:"results"`
}
type QueuedEvent struct {
Envelope
LeaseToken int64
AttemptCount int
}
type Disposition string
const (
Delivered Disposition = "delivered"
DeadLetter Disposition = "dead_letter"
Retry Disposition = "retry"
)
type Completion struct {
EventID string
LeaseToken int64
Disposition Disposition
ErrorCode string
RetryAfter time.Duration
}
var ErrLeaseLost = errors.New("audit relay lease lost")
type Repository interface {
AuditRelayReady(context.Context) error
ClaimAuditRelayBatch(context.Context, string, int, time.Duration) ([]QueuedEvent, error)
CompleteAuditRelayBatch(context.Context, string, []Completion) error
}
+126
View File
@@ -0,0 +1,126 @@
package auditrelay
import (
"context"
"errors"
"fmt"
"regexp"
"time"
)
var stableErrorCode = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
type Sender interface {
Send(context.Context, []Envelope) ([]Result, error)
}
type Worker struct {
repository Repository
sender Sender
owner string
}
func NewWorker(repository Repository, sender Sender, owner string) (*Worker, error) {
if repository == nil || sender == nil || owner == "" {
return nil, errors.New("audit relay worker dependencies are required")
}
return &Worker{repository: repository, sender: sender, owner: owner}, nil
}
func RetryDelay(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
if attempt > 9 {
return 300 * time.Second
}
delay := time.Second << (attempt - 1)
if delay > 300*time.Second {
return 300 * time.Second
}
return delay
}
func (w *Worker) RelayOnce(ctx context.Context) error {
queued, err := w.repository.ClaimAuditRelayBatch(ctx, w.owner, MaxBatchSize, LeaseDuration)
if err != nil || len(queued) == 0 {
return err
}
events := make([]Envelope, len(queued))
for index := range queued {
events[index] = queued[index].Envelope
}
results, sendErr := w.sender.Send(ctx, events)
if sendErr != nil {
completions := make([]Completion, len(queued))
for index, value := range queued {
completions[index] = Completion{EventID: value.Event.EventID, LeaseToken: value.LeaseToken, Disposition: Retry, ErrorCode: "delivery_failed", RetryAfter: RetryDelay(value.AttemptCount)}
}
if err := w.repository.CompleteAuditRelayBatch(ctx, w.owner, completions); err != nil {
return err
}
return sendErr
}
if len(results) != len(queued) {
return w.retryAll(ctx, queued, "invalid_response")
}
expected := make(map[string]bool, len(queued))
for _, value := range queued {
expected[value.Event.EventID] = true
}
byID := make(map[string]Result, len(results))
for _, result := range results {
validStatus := ((result.Status == "accepted" || result.Status == "duplicate") && result.ErrorCode == nil) ||
(result.Status == "rejected" && result.ErrorCode != nil && stableErrorCode.MatchString(*result.ErrorCode))
if !expected[result.EventID] || !validStatus {
return w.retryAll(ctx, queued, "invalid_response")
}
if _, duplicate := byID[result.EventID]; duplicate {
return w.retryAll(ctx, queued, "invalid_response")
}
byID[result.EventID] = result
}
completions := make([]Completion, 0, len(queued))
for _, value := range queued {
result, ok := byID[value.Event.EventID]
if !ok {
return w.retryAll(ctx, queued, "invalid_response")
}
switch result.Status {
case "accepted", "duplicate":
completions = append(completions, Completion{EventID: value.Event.EventID, LeaseToken: value.LeaseToken, Disposition: Delivered})
case "rejected":
completions = append(completions, Completion{EventID: value.Event.EventID, LeaseToken: value.LeaseToken, Disposition: DeadLetter, ErrorCode: *result.ErrorCode})
}
}
return w.repository.CompleteAuditRelayBatch(ctx, w.owner, completions)
}
func (w *Worker) retryAll(ctx context.Context, queued []QueuedEvent, code string) error {
values := make([]Completion, len(queued))
for index, value := range queued {
values[index] = Completion{EventID: value.Event.EventID, LeaseToken: value.LeaseToken, Disposition: Retry, ErrorCode: code, RetryAfter: RetryDelay(value.AttemptCount)}
}
if err := w.repository.CompleteAuditRelayBatch(ctx, w.owner, values); err != nil {
return err
}
return fmt.Errorf("audit relay %s", code)
}
func (w *Worker) Run(ctx context.Context, interval time.Duration, report func(error)) {
if interval <= 0 {
interval = time.Second
}
for {
if err := w.RelayOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
report(err)
}
timer := time.NewTimer(interval)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
}
+181
View File
@@ -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
}
+57
View File
@@ -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)
}
}
}
+180 -29
View File
@@ -6,33 +6,59 @@ import (
"net" "net"
"net/url" "net/url"
"os" "os"
"path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
) )
const ( const (
defaultHTTPAddress = "127.0.0.1:8080" defaultHTTPAddress = "127.0.0.1:8080"
defaultDatabaseDriver = "sqlite" defaultDatabaseDriver = "sqlite"
defaultDatabaseDSN = "file:data/sense.db" postgresDatabaseDriver = "postgres"
defaultMediaMTXURL = "http://127.0.0.1:9997" defaultDatabaseDSN = "file:data/sense.db"
defaultReconcilePeriod = 5 * time.Second defaultMediaMTXURL = "http://127.0.0.1:9997"
defaultProbePeriod = 10 * time.Second defaultReconcilePeriod = 5 * time.Second
defaultONVIFMode = "disabled" defaultReconcileLease = 30 * time.Second
defaultOperationTimeout = 20 * time.Second
defaultProbePeriod = 10 * time.Second
defaultOrphanScanPeriod = time.Minute
defaultONVIFMode = "disabled"
defaultControlAuthMode = "static-sha256"
defaultAuditRelayPeriod = time.Second
) )
var instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
type Config struct { type Config struct {
HTTPAddress string HTTPAddress string
AllowNonLoopback bool AllowNonLoopback bool
DatabaseDriver string DatabaseDriver string
DatabaseDSN string DatabaseDSN string
MediaMTXURL string MediaMTXURL string
ReconcileInterval time.Duration ReconcileInterval time.Duration
ProbeInterval time.Duration ReconcileLeaseDuration time.Duration
ONVIFMode string ReconcileOperationTimeout time.Duration
RTSPRewriteHost string ProbeInterval time.Duration
RTSPRewritePort int InstanceID string
RTSPStripQuery bool 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
AuditRelayEnabled bool
AuditRelayURL string
AuditRelayKeyFile string
AuditRelayKeyID string
AuditRelayInterval time.Duration
} }
func Load() (Config, error) { func Load() (Config, error) {
@@ -44,6 +70,14 @@ func Load() (Config, error) {
if err != nil { if err != nil {
return Config{}, err 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) probePeriod, err := durationEnv("SENSE_PROBE_INTERVAL", defaultProbePeriod)
if err != nil { if err != nil {
return Config{}, err return Config{}, err
@@ -56,19 +90,65 @@ func Load() (Config, error) {
if err != nil { if err != nil {
return Config{}, err 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
}
auditRelayEnabled, err := boolEnv("SENSE_AUDIT_RELAY_ENABLED", false)
if err != nil {
return Config{}, err
}
auditRelayInterval, err := durationEnv("SENSE_AUDIT_RELAY_INTERVAL", defaultAuditRelayPeriod)
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{ cfg := Config{
HTTPAddress: stringEnv("SENSE_HTTP_ADDR", defaultHTTPAddress), HTTPAddress: stringEnv("SENSE_HTTP_ADDR", defaultHTTPAddress),
AllowNonLoopback: allow, AllowNonLoopback: allow,
DatabaseDriver: stringEnv("SENSE_DB_DRIVER", defaultDatabaseDriver), DatabaseDriver: databaseDriver,
DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN), DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN),
MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL), MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL),
ReconcileInterval: reconcilePeriod, ReconcileInterval: reconcilePeriod,
ProbeInterval: probePeriod, ReconcileLeaseDuration: reconcileLease,
ONVIFMode: stringEnv("SENSE_ONVIF_MODE", defaultONVIFMode), ReconcileOperationTimeout: operationTimeout,
RTSPRewriteHost: stringEnv("SENSE_ONVIF_RTSP_REWRITE_HOST", ""), ProbeInterval: probePeriod,
RTSPRewritePort: rewritePort, InstanceID: stringEnv("SENSE_INSTANCE_ID", ""),
RTSPStripQuery: stripQuery, 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,
AuditRelayEnabled: auditRelayEnabled,
AuditRelayURL: stringEnv("SENSE_AUDIT_RELAY_URL", ""),
AuditRelayKeyFile: stringEnv("SENSE_AUDIT_RELAY_KEY_FILE", ""),
AuditRelayKeyID: stringEnv("SENSE_AUDIT_RELAY_KEY_ID", ""),
AuditRelayInterval: auditRelayInterval,
} }
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return Config{}, err return Config{}, err
@@ -109,6 +189,35 @@ func (c Config) Validate() error {
if c.ReconcileInterval <= 0 || c.ProbeInterval <= 0 { if c.ReconcileInterval <= 0 || c.ProbeInterval <= 0 {
return fmt.Errorf("loop intervals must be positive") 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" { if c.ONVIFMode != "" && c.ONVIFMode != "disabled" && c.ONVIFMode != "standard" {
return fmt.Errorf("SENSE_ONVIF_MODE must be disabled or standard") return fmt.Errorf("SENSE_ONVIF_MODE must be disabled or standard")
} }
@@ -121,6 +230,48 @@ func (c Config) Validate() error {
return fmt.Errorf("invalid SENSE_ONVIF_RTSP_REWRITE_HOST") 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")
}
}
if c.AuditRelayEnabled {
if databaseDriver != postgresDatabaseDriver {
return fmt.Errorf("Sense audit relay requires SENSE_DB_DRIVER=postgres")
}
if c.AuditRelayKeyFile == "" || !filepath.IsAbs(c.AuditRelayKeyFile) {
return fmt.Errorf("SENSE_AUDIT_RELAY_KEY_FILE must be an absolute external path")
}
if !instanceIDPattern.MatchString(c.AuditRelayKeyID) {
return fmt.Errorf("invalid SENSE_AUDIT_RELAY_KEY_ID")
}
if c.AuditRelayInterval < time.Second {
return fmt.Errorf("SENSE_AUDIT_RELAY_INTERVAL must be at least 1s")
}
relayURL, err := url.Parse(c.AuditRelayURL)
if err != nil || relayURL.Host == "" || relayURL.Path != "/internal/v1/audit-events:batch" ||
relayURL.RawQuery != "" || relayURL.Fragment != "" || relayURL.User != nil {
return fmt.Errorf("invalid SENSE_AUDIT_RELAY_URL")
}
relayHost := relayURL.Hostname()
relayIP := net.ParseIP(relayHost)
relayLoopback := relayHost == "localhost" || (relayIP != nil && relayIP.IsLoopback())
if relayURL.Scheme != "https" && !(relayURL.Scheme == "http" && relayLoopback) {
return fmt.Errorf("SENSE_AUDIT_RELAY_URL requires HTTPS outside loopback")
}
}
return nil return nil
} }
+102 -1
View File
@@ -1,6 +1,10 @@
package config package config
import "testing" import (
"path/filepath"
"testing"
"time"
)
func TestValidateRejectsNonLoopbackByDefault(t *testing.T) { func TestValidateRejectsNonLoopbackByDefault(t *testing.T) {
t.Parallel() t.Parallel()
@@ -20,6 +24,35 @@ func TestValidateRejectsNonLoopbackByDefault(t *testing.T) {
} }
} }
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) { func TestValidateRejectsCredentialsInMediaMTXURL(t *testing.T) {
t.Parallel() t.Parallel()
cfg := Config{ cfg := Config{
@@ -93,3 +126,71 @@ func TestValidateDatabaseDriver(t *testing.T) {
t.Fatal("unknown database driver must be rejected") 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)
}
}
func TestValidateAuditRelaySecurityBoundary(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,
AuditRelayEnabled: true, AuditRelayURL: "http://127.0.0.1:8081/internal/v1/audit-events:batch",
AuditRelayKeyFile: filepath.Join(t.TempDir(), "relay-keys.json"), AuditRelayKeyID: "sense-a", AuditRelayInterval: time.Second,
}
if err := base.Validate(); err != nil {
t.Fatalf("valid loopback relay rejected: %v", err)
}
remoteHTTP := base
remoteHTTP.AuditRelayURL = "http://bell.example/internal/v1/audit-events:batch"
if err := remoteHTTP.Validate(); err == nil {
t.Fatal("remote plaintext relay was accepted")
}
sqlite := base
sqlite.DatabaseDriver, sqlite.DatabaseDSN = "sqlite", "file:test.db"
if err := sqlite.Validate(); err == nil {
t.Fatal("SQLite audit relay was accepted")
}
relativeKey := base
relativeKey.AuditRelayKeyFile = "relay-keys.json"
if err := relativeKey.Validate(); err == nil {
t.Fatal("repository-relative relay key was accepted")
}
}
+5
View File
@@ -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
+42
View File
@@ -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
}
+66
View File
@@ -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
+108
View File
@@ -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)
}
}
+601
View File
@@ -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, &quotaError) {
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)
+272
View File
@@ -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: &quotaVersion, 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)
}
}
}
+153
View File
@@ -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, ",")
}
+19
View File
@@ -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,
})
}
+20 -15
View File
@@ -75,21 +75,26 @@ func (s Site) Validate() error {
} }
type Device struct { type Device struct {
ID string ID string
TenantID string TenantID string
SiteID string SiteID string
SerialNumber string AreaID string
Name string SerialNumber string
Modality Modality Name string
Capabilities []Capability Modality Modality
DesiredState DesiredState Capabilities []Capability
ActualState ActualState DesiredState DesiredState
EndpointRef string ActualState ActualState
CredentialRef string EndpointRef string
PathName string CredentialRef string
Generation int64 ProfileToken string
CreatedAt time.Time PathName string
UpdatedAt time.Time Generation int64
ResourceVersion int64
QuotaSourceVersion int64
AreaPolicySourceVersion int64
CreatedAt time.Time
UpdatedAt time.Time
} }
func (d Device) Validate() error { func (d Device) Validate() error {
+189
View File
@@ -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) + `"`
}
+84
View File
@@ -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)
}
}
+58 -1
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"sort"
"strings" "strings"
mediamtxapi "yovision/sense/internal/mtx/generated" mediamtxapi "yovision/sense/internal/mtx/generated"
@@ -31,9 +32,63 @@ type pathAPI interface {
ConfigPathsGetWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsGetResponse, error) ConfigPathsGetWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsGetResponse, error)
ConfigPathsPatchWithResponse(context.Context, string, mediamtxapi.ConfigPathsPatchJSONRequestBody, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsPatchResponse, error) ConfigPathsPatchWithResponse(context.Context, string, mediamtxapi.ConfigPathsPatchJSONRequestBody, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsPatchResponse, error)
ConfigPathsDeleteWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsDeleteResponse, 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) 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 { type Client struct {
api pathAPI api pathAPI
} }
@@ -92,7 +147,9 @@ func (c *Client) DeletePath(ctx context.Context, name string) error {
return fmt.Errorf("MediaMTX delete path transport: %w", err) return fmt.Errorf("MediaMTX delete path transport: %w", err)
} }
if response.StatusCode() == http.StatusNotFound { if response.StatusCode() == http.StatusNotFound {
return ErrPathNotFound // Deletion is an idempotent convergence operation. A missing exact path
// already satisfies the disabled desired state.
return nil
} }
if response.StatusCode() != http.StatusOK { if response.StatusCode() != http.StatusOK {
return &APIError{Operation: "delete path", StatusCode: response.StatusCode()} return &APIError{Operation: "delete path", StatusCode: response.StatusCode()}
+82
View File
@@ -6,6 +6,8 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sort"
"strconv"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -21,6 +23,38 @@ func (f *fakeMediaMTX) ServeHTTP(writer http.ResponseWriter, request *http.Reque
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
writer.Header().Set("Content-Type", "application/json") 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{ prefixes := map[string]string{
"/v3/config/paths/get/": "get", "/v3/config/paths/get/": "get",
"/v3/config/paths/add/": "add", "/v3/config/paths/add/": "add",
@@ -72,6 +106,54 @@ func (f *fakeMediaMTX) ServeHTTP(writer http.ResponseWriter, request *http.Reque
http.NotFound(writer, request) 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) { func TestGeneratedClientCreateReadDeleteMapping(t *testing.T) {
t.Parallel() t.Parallel()
fake := &fakeMediaMTX{paths: make(map[string]string)} fake := &fakeMediaMTX{paths: make(map[string]string)}
+400
View File
@@ -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}, " ")
}
+272
View File
@@ -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")
}
}
+148 -20
View File
@@ -3,11 +3,15 @@ package reconcile
import ( import (
"context" "context"
"crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"time" "time"
"yovision/sense/internal/device"
"yovision/sense/internal/metrics"
"yovision/sense/internal/onvif" "yovision/sense/internal/onvif"
"yovision/sense/internal/store" "yovision/sense/internal/store"
) )
@@ -15,51 +19,149 @@ import (
const defaultBatchSize = 128 const defaultBatchSize = 128
type Repository interface { type Repository interface {
ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]store.ReconcileCandidate, error) ClaimDueReconcile(context.Context, store.ReconcileClaim) ([]store.ReconcileCandidate, error)
MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error RenewReconcileLease(context.Context, string, string, string, time.Time, time.Duration) (bool, error)
MarkReconcileFailure(ctx context.Context, id string, failureCount int, nextAttempt time.Time, errorCode string, now time.Time) 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 { type MediaPaths interface {
EnsurePath(ctx context.Context, name, source string) (bool, error) EnsurePath(ctx context.Context, name, source string) (bool, error)
DeletePath(ctx context.Context, name string) error
} }
type Reconciler struct { type Reconciler struct {
repository Repository repository Repository
discovery onvif.Adapter discovery onvif.Adapter
media MediaPaths media MediaPaths
now func() time.Time now func() time.Time
baseBackoff time.Duration baseBackoff time.Duration
maxBackoff time.Duration maxBackoff time.Duration
batchSize int 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 { 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{ return &Reconciler{
repository: repository, discovery: discovery, media: media, repository: repository, discovery: discovery, media: media,
now: time.Now, baseBackoff: time.Second, maxBackoff: time.Minute, batchSize: defaultBatchSize, 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) error { 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() now := r.now().UTC()
candidates, err := r.repository.ListDueReconcile(ctx, now, r.batchSize) token, err := newClaimToken()
if err != nil { if err != nil {
return fmt.Errorf("list reconciliation candidates: %w", err) 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 var runErrors []error
for _, candidate := range candidates { for _, candidate := range candidates {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return err return err
} }
if err := r.reconcileOne(ctx, candidate, now); err != nil { 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)) 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...) return errors.Join(runErrors...)
} }
func (r *Reconciler) reconcileOne(ctx context.Context, candidate store.ReconcileCandidate, now time.Time) error { 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{ result, err := r.discovery.Probe(ctx, onvif.Target{
EndpointRef: candidate.Device.EndpointRef, CredentialRef: candidate.Device.CredentialRef, EndpointRef: candidate.Device.EndpointRef, CredentialRef: candidate.Device.CredentialRef,
}) })
@@ -70,11 +172,23 @@ func (r *Reconciler) reconcileOne(ctx context.Context, candidate store.Reconcile
_, err = r.media.EnsurePath(ctx, candidate.Device.PathName, result.StreamURI) _, err = r.media.EnsurePath(ctx, candidate.Device.PathName, result.StreamURI)
} }
if err == nil { if err == nil {
return r.repository.MarkReconciled(ctx, candidate.Device.ID, candidate.Device.Generation, now) return r.repository.CompleteReconcile(
persistCtx, candidate.Device.ID, candidate.Device.Generation, owner, token, now,
)
} }
if ctx.Err() != nil { if persistCtx.Err() != nil {
return ctx.Err() 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 failureCount := candidate.FailureCount + 1
nextAttempt := now.Add(r.backoff(failureCount)) nextAttempt := now.Add(r.backoff(failureCount))
errorCode := string(onvif.CodeOf(err)) errorCode := string(onvif.CodeOf(err))
@@ -82,14 +196,28 @@ func (r *Reconciler) reconcileOne(ctx context.Context, candidate store.Reconcile
if !errors.As(err, &onvifError) { if !errors.As(err, &onvifError) {
errorCode = "media_error" errorCode = "media_error"
} }
if markErr := r.repository.MarkReconcileFailure( if markErr := r.repository.FailReconcile(
ctx, candidate.Device.ID, failureCount, nextAttempt, errorCode, now, ctx, candidate.Device.ID, failureCount, nextAttempt, errorCode, owner, token, now,
); markErr != nil { ); markErr != nil {
return errors.Join(err, fmt.Errorf("persist reconcile failure: %w", markErr)) return errors.Join(err, fmt.Errorf("persist reconcile failure: %w", markErr))
} }
return err 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 { func validateStreamURI(value string) error {
parsed, err := url.Parse(value) parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" || (parsed.Scheme != "rtsp" && parsed.Scheme != "rtsps") { if err != nil || parsed.Host == "" || (parsed.Scheme != "rtsp" && parsed.Scheme != "rtsps") {
+110
View File
@@ -15,6 +15,7 @@ import (
type recordingMedia struct { type recordingMedia struct {
calls int calls int
changed int changed int
deleted int
paths map[string]string paths map[string]string
} }
@@ -31,6 +32,15 @@ func (m *recordingMedia) EnsurePath(_ context.Context, name, source string) (boo
return true, nil 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) { func TestReconcileConvergesOnceAndPersistsGeneration(t *testing.T) {
t.Parallel() t.Parallel()
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db")) repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
@@ -132,6 +142,106 @@ func TestCancellationDoesNotPersistFailure(t *testing.T) {
} }
} }
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 { func openRepository(t *testing.T, path string) *store.SQLite {
t.Helper() t.Helper()
repository, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(path)) repository, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(path))
+127
View File
@@ -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,176 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"regexp"
"strings"
"time"
"yovision/sense/internal/auditrelay"
)
var relayErrorCode = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
func (s *Postgres) AuditRelayReady(ctx context.Context) error {
var version int64
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM sense.schema_migrations`).Scan(&version); err != nil || version < 6 {
return errors.New("postgres Sense schema migration v6 is required for audit relay")
}
var canUseOutbox, canReadBell, canWriteBell bool
if err := s.db.QueryRowContext(ctx, `SELECT
has_table_privilege(current_user, 'sense.device_operation_outbox', 'SELECT,INSERT,UPDATE,DELETE'),
has_table_privilege(current_user, 'bell.audit_events', 'SELECT'),
has_table_privilege(current_user, 'bell.audit_events', 'INSERT,UPDATE,DELETE')`).Scan(
&canUseOutbox, &canReadBell, &canWriteBell,
); err != nil {
return errors.New("verify audit relay privileges")
}
if !canUseOutbox || canReadBell || canWriteBell {
return errors.New("Sense audit relay violates schema ownership boundary")
}
return nil
}
func (s *Postgres) ClaimAuditRelayBatch(
ctx context.Context,
owner string,
limit int,
lease time.Duration,
) ([]auditrelay.QueuedEvent, error) {
if strings.TrimSpace(owner) == "" || limit < 1 || limit > auditrelay.MaxBatchSize || lease <= 0 {
return nil, errors.New("invalid audit relay claim")
}
rows, err := s.db.QueryContext(ctx, `WITH due AS (
SELECT event_id FROM sense.device_operation_outbox
WHERE delivered_at IS NULL AND dead_lettered_at IS NULL
AND COALESCE(next_attempt_at, available_at) <= clock_timestamp()
AND (relay_lease_until IS NULL OR relay_lease_until <= clock_timestamp())
ORDER BY COALESCE(next_attempt_at, available_at), event_id
FOR UPDATE SKIP LOCKED LIMIT $1
)
UPDATE sense.device_operation_outbox AS outbox SET
relay_lease_owner = $2,
relay_lease_token = outbox.relay_lease_token + 1,
relay_lease_until = clock_timestamp() + ($3 * interval '1 second'),
attempt_count = outbox.attempt_count + 1,
last_error_code = NULL
FROM due WHERE outbox.event_id = due.event_id
RETURNING outbox.event_id, outbox.event_type, outbox.tenant_id,
outbox.site_id, outbox.device_id, outbox.actor_type, outbox.actor_id,
outbox.reason, outbox.trace_id, outbox.aggregate_generation,
outbox.quota_source_version, outbox.area_policy_source_version,
outbox.payload, outbox.occurred_at, outbox.relay_lease_token,
outbox.attempt_count`, limit, owner, lease.Seconds())
if err != nil {
return nil, errors.New("claim audit relay batch")
}
defer rows.Close()
values := make([]auditrelay.QueuedEvent, 0)
for rows.Next() {
var value auditrelay.QueuedEvent
var reason, trace sql.NullString
var quota, area sql.NullInt64
var payload []byte
if err := rows.Scan(
&value.Event.EventID, &value.Event.EventType, &value.Event.TenantID,
&value.Event.SiteID, &value.Event.DeviceID, &value.Event.Actor.Type,
&value.Event.Actor.ID, &reason, &trace, &value.Event.AggregateGeneration,
&quota, &area, &payload, &value.Event.OccurredAt, &value.LeaseToken,
&value.AttemptCount,
); err != nil {
return nil, errors.New("scan audit relay claim")
}
if reason.Valid {
value.Event.Reason = &reason.String
}
if trace.Valid {
value.Event.TraceID = &trace.String
}
if quota.Valid {
value.Event.ProjectionVersions.QuotaSourceVersion = &quota.Int64
}
if area.Valid {
value.Event.ProjectionVersions.AreaPolicySourceVersion = &area.Int64
}
if !json.Valid(payload) {
return nil, errors.New("invalid audit payload in outbox")
}
value.Event.Data = append(json.RawMessage(nil), payload...)
value.SchemaVersion = 1
if value.Event.EventType == "device.configuration.accepted" {
value.SchemaVersion = 2
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, errors.New("iterate audit relay claims")
}
return values, nil
}
func (s *Postgres) CompleteAuditRelayBatch(
ctx context.Context,
owner string,
values []auditrelay.Completion,
) error {
if strings.TrimSpace(owner) == "" || len(values) == 0 || len(values) > auditrelay.MaxBatchSize {
return errors.New("invalid audit relay completion")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin audit relay completion")
}
defer tx.Rollback()
for _, value := range values {
code := value.ErrorCode
if code != "" && !relayErrorCode.MatchString(code) {
code = "invalid_response"
}
var result sql.Result
switch value.Disposition {
case auditrelay.Delivered:
result, err = tx.ExecContext(ctx, `UPDATE sense.device_operation_outbox SET
delivered_at = clock_timestamp(), last_error_code = NULL,
relay_lease_owner = NULL, relay_lease_until = NULL
WHERE event_id = $1 AND relay_lease_owner = $2 AND relay_lease_token = $3
AND relay_lease_until > clock_timestamp()`, value.EventID, owner, value.LeaseToken)
case auditrelay.DeadLetter:
if code == "" {
return errors.New("dead-letter completion requires an error code")
}
result, err = tx.ExecContext(ctx, `UPDATE sense.device_operation_outbox SET
dead_lettered_at = clock_timestamp(), last_error_code = $4,
relay_lease_owner = NULL, relay_lease_until = NULL
WHERE event_id = $1 AND relay_lease_owner = $2 AND relay_lease_token = $3
AND relay_lease_until > clock_timestamp()`, value.EventID, owner, value.LeaseToken, code)
case auditrelay.Retry:
if code == "" || value.RetryAfter < time.Second || value.RetryAfter > 300*time.Second {
return errors.New("invalid audit relay retry completion")
}
result, err = tx.ExecContext(ctx, `UPDATE sense.device_operation_outbox SET
next_attempt_at = clock_timestamp() + ($4 * interval '1 second'),
last_error_code = $5, relay_lease_owner = NULL, relay_lease_until = NULL
WHERE event_id = $1 AND relay_lease_owner = $2 AND relay_lease_token = $3
AND relay_lease_until > clock_timestamp()`, value.EventID, owner, value.LeaseToken, value.RetryAfter.Seconds(), code)
default:
return errors.New("invalid audit relay disposition")
}
if err != nil {
return errors.New("persist audit relay completion")
}
affected, err := result.RowsAffected()
if err != nil {
return errors.New("read audit relay completion")
}
if affected != 1 {
return auditrelay.ErrLeaseLost
}
}
if err := tx.Commit(); err != nil {
return errors.New("commit audit relay completion")
}
return nil
}
@@ -0,0 +1,45 @@
package store
import (
"context"
"errors"
"testing"
"time"
"yovision/sense/internal/auditrelay"
)
func TestPostgresAuditRelayClaimUsesFencing(t *testing.T) {
repository, admin := openPostgresTestStore(t)
ctx := context.Background()
if err := repository.AuditRelayReady(ctx); err != nil {
t.Fatal(err)
}
insertBellSite(t, admin, "relay-tenant", "relay-site", 1)
device := videoDevice(9001, "relay-tenant", "relay-site")
if err := repository.CreateDevice(ctx, device); err != nil {
t.Fatal(err)
}
first, err := repository.ClaimAuditRelayBatch(ctx, "worker-a", 100, 30*time.Second)
if err != nil || len(first) != 1 || first[0].SchemaVersion != 1 || first[0].AttemptCount != 1 {
t.Fatalf("first claim: %+v %v", first, err)
}
if _, err := admin.ExecContext(ctx, `UPDATE sense.device_operation_outbox SET relay_lease_until=clock_timestamp()-interval '1 second' WHERE event_id=$1`, first[0].Event.EventID); err != nil {
t.Fatal(err)
}
second, err := repository.ClaimAuditRelayBatch(ctx, "worker-b", 100, 30*time.Second)
if err != nil || len(second) != 1 || second[0].LeaseToken <= first[0].LeaseToken {
t.Fatalf("reclaim: %+v %v", second, err)
}
err = repository.CompleteAuditRelayBatch(ctx, "worker-a", []auditrelay.Completion{{EventID: first[0].Event.EventID, LeaseToken: first[0].LeaseToken, Disposition: auditrelay.Delivered}})
if !errors.Is(err, auditrelay.ErrLeaseLost) {
t.Fatalf("stale worker completion returned %v", err)
}
if err := repository.CompleteAuditRelayBatch(ctx, "worker-b", []auditrelay.Completion{{EventID: second[0].Event.EventID, LeaseToken: second[0].LeaseToken, Disposition: auditrelay.Delivered}}); err != nil {
t.Fatal(err)
}
var delivered bool
if err := admin.QueryRowContext(ctx, `SELECT delivered_at IS NOT NULL FROM sense.device_operation_outbox WHERE event_id=$1`, first[0].Event.EventID).Scan(&delivered); err != nil || !delivered {
t.Fatalf("delivered=%v err=%v", delivered, err)
}
}
+164
View File
@@ -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]) + `"`
}
+891
View File
@@ -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, &quotaVersion, &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, &quotaVersion, &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, &current, &endpointRef, &pathName, &generation, &resourceVersion,
&quotaVersion, &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(&quotaVersion, &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, &quotaError) {
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
}
+58
View File
@@ -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)
+268
View File
@@ -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
}
+450 -40
View File
@@ -16,7 +16,7 @@ import (
) )
// Postgres persists Sense state in the sense schema and consumes only Bell's // Postgres persists Sense state in the sense schema and consumes only Bell's
// versioned quota view. Migrations are deliberately installed out of process. // versioned quota and Area views. Migrations are installed out of process.
type Postgres struct { type Postgres struct {
db *sql.DB db *sql.DB
} }
@@ -53,20 +53,69 @@ func (s *Postgres) Close() error {
func (s *Postgres) verifySchemaAndPrivileges(ctx context.Context) error { func (s *Postgres) verifySchemaAndPrivileges(ctx context.Context) error {
var version sql.NullInt64 var version sql.NullInt64
if err := s.db.QueryRowContext(ctx, if err := s.db.QueryRowContext(ctx,
`SELECT MAX(version) FROM sense.schema_migrations`).Scan(&version); err != nil || !version.Valid || version.Int64 < 1 { `SELECT MAX(version) FROM sense.schema_migrations`).Scan(&version); err != nil || !version.Valid || version.Int64 < 5 {
return errors.New("postgres sense schema migration v1 is required") return errors.New("postgres sense schema migration v5 is required")
} }
var canReadView, canWriteView, canReadSource, canWriteSource bool var canReadQuotaView, canWriteQuotaView, canReadSiteSource, canWriteSiteSource bool
var canReadAreaView, canWriteAreaView, canReadAreaSource, canWriteAreaSource bool
if err := s.db.QueryRowContext(ctx, `SELECT 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', 'SELECT'),
has_table_privilege(current_user, 'bell.site_quota_v1', 'INSERT,UPDATE,DELETE'), 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', 'SELECT'),
has_table_privilege(current_user, 'bell.sites', 'INSERT,UPDATE,DELETE')`). has_table_privilege(current_user, 'bell.sites', 'INSERT,UPDATE,DELETE'),
Scan(&canReadView, &canWriteView, &canReadSource, &canWriteSource); err != nil { has_table_privilege(current_user, 'bell.area_policy_v1', 'SELECT'),
return errors.New("verify postgres quota privileges") 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 !canReadView || canWriteView || canReadSource || canWriteSource { if !canReadQuotaView || canWriteQuotaView || canReadSiteSource || canWriteSiteSource ||
return errors.New("postgres role violates Bell quota privilege boundary") !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 return nil
} }
@@ -78,6 +127,9 @@ func (s *Postgres) CreateDevice(ctx context.Context, value device.Device) error
if value.ActualState == "" { if value.ActualState == "" {
value.ActualState = device.ActualPending value.ActualState = device.ActualPending
} }
if value.ResourceVersion == 0 {
value.ResourceVersion = 1
}
if err := value.Validate(); err != nil { if err := value.Validate(); err != nil {
return fmt.Errorf("validate device: %w", err) return fmt.Errorf("validate device: %w", err)
} }
@@ -92,7 +144,14 @@ func (s *Postgres) CreateDevice(ctx context.Context, value device.Device) error
return errors.New("begin postgres create device") return errors.New("begin postgres create device")
} }
defer tx.Rollback() defer tx.Rollback()
var quotaVersion any 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() { if value.ConsumesVideoChannel() {
version, quotaErr := checkPostgresVideoQuota(ctx, tx, value.TenantID, value.SiteID, now) version, quotaErr := checkPostgresVideoQuota(ctx, tx, value.TenantID, value.SiteID, now)
if quotaErr != nil { if quotaErr != nil {
@@ -101,13 +160,15 @@ func (s *Postgres) CreateDevice(ctx context.Context, value device.Device) error
quotaVersion = version quotaVersion = version
} }
_, err = tx.ExecContext(ctx, `INSERT INTO sense.devices( _, err = tx.ExecContext(ctx, `INSERT INTO sense.devices(
id, tenant_id, site_id, serial_number, name, modality, id, tenant_id, site_id, area_id, serial_number, name, modality,
desired_state, actual_state, endpoint_ref, credential_ref, desired_state, actual_state, endpoint_ref, credential_ref, profile_token,
path_name, generation, quota_source_version, created_at, updated_at path_name, generation, resource_version, quota_source_version, area_policy_source_version,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`, created_at, updated_at
value.ID, value.TenantID, value.SiteID, value.SerialNumber, value.Name, ) 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.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
value.CredentialRef, value.PathName, value.Generation, quotaVersion, value.CredentialRef, value.ProfileToken, value.PathName, value.Generation,
value.ResourceVersion, nullableVersion(quotaVersion), areaVersion,
value.CreatedAt, value.UpdatedAt) value.CreatedAt, value.UpdatedAt)
if err != nil { if err != nil {
return errors.New("insert postgres device") return errors.New("insert postgres device")
@@ -123,12 +184,83 @@ func (s *Postgres) CreateDevice(ctx context.Context, value device.Device) error
VALUES ($1, $2)`, value.ID, now); err != nil { VALUES ($1, $2)`, value.ID, now); err != nil {
return errors.New("insert postgres reconcile state") 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 { if err := tx.Commit(); err != nil {
return errors.New("commit postgres create device") return errors.New("commit postgres create device")
} }
return nil 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( func checkPostgresVideoQuota(
ctx context.Context, ctx context.Context,
tx *sql.Tx, tx *sql.Tx,
@@ -199,10 +331,17 @@ func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired devic
} }
defer tx.Rollback() defer tx.Rollback()
var tenantID, siteID, endpointRef, pathName string var tenantID, siteID, endpointRef, pathName string
var areaID sql.NullString
var current device.DesiredState var current device.DesiredState
err = tx.QueryRowContext(ctx, `SELECT tenant_id, site_id, desired_state, endpoint_ref, path_name 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). FROM sense.devices WHERE id = $1 FOR UPDATE`, id).
Scan(&tenantID, &siteID, &current, &endpointRef, &pathName) Scan(
&tenantID, &siteID, &areaID, &current, &endpointRef, &pathName,
&generation, &storedQuotaVersion, &storedAreaVersion,
)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound return ErrNotFound
} }
@@ -210,9 +349,25 @@ func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired devic
return errors.New("read postgres device desired state") return errors.New("read postgres device desired state")
} }
if current == desired { if current == desired {
return tx.Commit() 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 any var quotaVersion, areaVersion int64
if desired == device.DesiredEnabled { if desired == device.DesiredEnabled {
var hasVideo bool var hasVideo bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS( if err := tx.QueryRowContext(ctx, `SELECT EXISTS(
@@ -225,6 +380,13 @@ func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired devic
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" { if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
return errors.New("enabled video devices require endpoint ref and path name") 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()) version, quotaErr := checkPostgresVideoQuota(ctx, tx, tenantID, siteID, time.Now().UTC())
if quotaErr != nil { if quotaErr != nil {
return quotaErr return quotaErr
@@ -232,21 +394,43 @@ func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired devic
quotaVersion = version quotaVersion = version
} }
} }
result, err := tx.ExecContext(ctx, `UPDATE sense.devices SET 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, desired_state = $1, actual_state = 'pending', generation = generation + 1,
quota_source_version = COALESCE($2, quota_source_version), updated_at = $3 resource_version = resource_version + 1,
WHERE id = $4`, desired, quotaVersion, time.Now().UTC(), id) 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 { if err != nil {
return errors.New("update postgres desired state") return errors.New("update postgres desired state")
} }
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL, updated_at = $1 failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
WHERE device_id = $2`, time.Now().UTC(), id); err != nil { 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") 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 { if err := tx.Commit(); err != nil {
return errors.New("commit postgres desired-state update") return errors.New("commit postgres desired-state update")
} }
@@ -275,10 +459,10 @@ func (s *Postgres) ListDueReconcile(ctx context.Context, now time.Time, limit in
rows, err := s.db.QueryContext(ctx, `SELECT `+postgresDeviceColumns+`, r.failure_count, r.next_attempt_at rows, err := s.db.QueryContext(ctx, `SELECT `+postgresDeviceColumns+`, r.failure_count, r.next_attempt_at
FROM sense.devices d FROM sense.devices d
JOIN sense.reconcile_state r ON r.device_id = d.id JOIN sense.reconcile_state r ON r.device_id = d.id
WHERE d.desired_state = 'enabled' WHERE EXISTS (SELECT 1 FROM sense.device_capabilities c
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture') WHERE c.device_id = d.id AND c.capability = 'video_capture')
AND (r.observed_generation < d.generation OR r.failure_count > 0) 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) AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= $1)
ORDER BY d.updated_at, d.id LIMIT $2`, now, limit) ORDER BY d.updated_at, d.id LIMIT $2`, now, limit)
if err != nil { if err != nil {
@@ -288,18 +472,25 @@ func (s *Postgres) ListDueReconcile(ctx context.Context, now time.Time, limit in
values := make([]ReconcileCandidate, 0) values := make([]ReconcileCandidate, 0)
for rows.Next() { for rows.Next() {
var candidate ReconcileCandidate var candidate ReconcileCandidate
var areaID sql.NullString
var quotaVersion, areaVersion sql.NullInt64
var nextAttempt sql.NullTime var nextAttempt sql.NullTime
if err := rows.Scan( if err := rows.Scan(
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID, &candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
&candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality, &areaID, &candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
&candidate.Device.DesiredState, &candidate.Device.ActualState, &candidate.Device.DesiredState, &candidate.Device.ActualState,
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef, &candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
&candidate.Device.PathName, &candidate.Device.Generation, &candidate.Device.ProfileToken, &candidate.Device.PathName,
&candidate.Device.Generation, &candidate.Device.ResourceVersion,
&quotaVersion, &areaVersion,
&candidate.Device.CreatedAt, &candidate.Device.UpdatedAt, &candidate.Device.CreatedAt, &candidate.Device.UpdatedAt,
&candidate.FailureCount, &nextAttempt, &candidate.FailureCount, &nextAttempt,
); err != nil { ); err != nil {
return nil, errors.New("scan postgres due reconcile device") 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 { if nextAttempt.Valid {
value := nextAttempt.Time value := nextAttempt.Time
candidate.NextAttempt = &value candidate.NextAttempt = &value
@@ -318,6 +509,217 @@ func (s *Postgres) ListDueReconcile(ctx context.Context, now time.Time, limit in
return values, nil 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,
&quotaVersion, &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) { func (s *Postgres) ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error) {
if limit <= 0 { if limit <= 0 {
return nil, nil return nil, nil
@@ -367,7 +769,8 @@ func (s *Postgres) MarkReconciled(ctx context.Context, id string, generation int
return ErrNotFound return ErrNotFound
} }
if _, err := tx.ExecContext(ctx, `UPDATE sense.devices if _, err := tx.ExecContext(ctx, `UPDATE sense.devices
SET actual_state = 'pending', updated_at = $1 WHERE id = $2`, now, id); err != nil { 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") return errors.New("mark postgres reconciled device pending")
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
@@ -483,20 +886,27 @@ func (s *Postgres) ConvergenceSnapshot(ctx context.Context) (ConvergenceSnapshot
return snapshot, nil return snapshot, nil
} }
const postgresDeviceColumns = `d.id, d.tenant_id, d.site_id, d.serial_number, d.name, d.modality, 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.desired_state, d.actual_state, d.endpoint_ref, d.credential_ref, d.profile_token,
d.path_name, d.generation, d.created_at, d.updated_at` 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` const postgresDeviceSelect = `SELECT ` + postgresDeviceColumns + ` FROM sense.devices d`
func scanPostgresDevice(row scanner) (device.Device, error) { func scanPostgresDevice(row scanner) (device.Device, error) {
var value device.Device var value device.Device
var areaID sql.NullString
var quotaVersion, areaVersion sql.NullInt64
err := row.Scan( err := row.Scan(
&value.ID, &value.TenantID, &value.SiteID, &value.SerialNumber, &value.ID, &value.TenantID, &value.SiteID, &areaID, &value.SerialNumber,
&value.Name, &value.Modality, &value.DesiredState, &value.ActualState, &value.Name, &value.Modality, &value.DesiredState, &value.ActualState,
&value.EndpointRef, &value.CredentialRef, &value.PathName, &value.EndpointRef, &value.CredentialRef, &value.ProfileToken, &value.PathName,
&value.Generation, &value.CreatedAt, &value.UpdatedAt, &value.Generation, &value.ResourceVersion, &quotaVersion, &areaVersion,
&value.CreatedAt, &value.UpdatedAt,
) )
value.AreaID = areaID.String
value.QuotaSourceVersion = quotaVersion.Int64
value.AreaPolicySourceVersion = areaVersion.Int64
return value, err return value, err
} }
+891 -4
View File
@@ -2,6 +2,7 @@ package store
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -48,6 +49,7 @@ func TestPostgresDefaultAndMaximumQuota(t *testing.T) {
} }
radar := device.Device{ radar := device.Device{
ID: "radar-default", TenantID: "tenant-default", SiteID: "site-default", ID: "radar-default", TenantID: "tenant-default", SiteID: "site-default",
AreaID: "area-default",
SerialNumber: "radar-default", Name: "Radar", Modality: device.ModalityRadar, SerialNumber: "radar-default", Name: "Radar", Modality: device.ModalityRadar,
Capabilities: []device.Capability{device.CapabilityTelemetry}, Capabilities: []device.Capability{device.CapabilityTelemetry},
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending, DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
@@ -113,8 +115,8 @@ func TestPostgresProjectionFailureAndRollbackFailClosed(t *testing.T) {
store, admin := openPostgresTestStore(t) store, admin := openPostgresTestStore(t)
ctx := context.Background() ctx := context.Background()
err := store.CreateDevice(ctx, videoDevice(1, "missing-tenant", "missing-site")) err := store.CreateDevice(ctx, videoDevice(1, "missing-tenant", "missing-site"))
if !errors.Is(err, ErrQuotaProjectionUnavailable) { if !errors.Is(err, ErrAreaPolicyUnavailable) {
t.Fatalf("missing projection must fail closed, got %v", err) t.Fatalf("missing Area projection must fail closed first, got %v", err)
} }
insertBellSite(t, admin, "tenant", "site", 2) insertBellSite(t, admin, "tenant", "site", 2)
@@ -285,12 +287,12 @@ func TestPostgresOpenRejectsOverprivilegedRuntimeRole(t *testing.T) {
_, admin := openPostgresTestStore(t) _, admin := openPostgresTestStore(t)
ctx := context.Background() ctx := context.Background()
if _, err := admin.ExecContext(ctx, if _, err := admin.ExecContext(ctx,
`GRANT UPDATE ON bell.site_quota_v1 TO yovision_t009_sense`); err != nil { `GRANT UPDATE ON bell.site_quota_v1 TO yovision_t012_sense`); err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer func() { defer func() {
_, _ = admin.ExecContext(context.Background(), _, _ = admin.ExecContext(context.Background(),
`REVOKE UPDATE ON bell.site_quota_v1 FROM yovision_t009_sense`) `REVOKE UPDATE ON bell.site_quota_v1 FROM yovision_t012_sense`)
}() }()
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv)) value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
if value != nil { if value != nil {
@@ -302,6 +304,866 @@ func TestPostgresOpenRejectsOverprivilegedRuntimeRole(t *testing.T) {
} }
} }
func TestPostgresOpenRejectsPublicReconciliationStatePrivilege(t *testing.T) {
_, admin := openPostgresTestStore(t)
ctx := context.Background()
if _, err := admin.ExecContext(ctx,
`GRANT SELECT ON sense.orphan_scan_runs TO PUBLIC`); err != nil {
t.Fatal(err)
}
defer func() {
_, _ = admin.ExecContext(context.Background(),
`REVOKE SELECT ON sense.orphan_scan_runs FROM PUBLIC`)
}()
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
if value != nil {
_ = value.Close()
t.Fatal("PUBLIC reconciliation state privilege was accepted")
}
if err == nil || !strings.Contains(err.Error(), "reconciliation safety privilege boundary") {
t.Fatalf("expected reconciliation privilege-boundary error, got %v", err)
}
}
func TestPostgresAreaPolicyAllowsNonImagingAndDeniesImagingCreate(t *testing.T) {
store, admin := openPostgresTestStore(t)
ctx := context.Background()
insertBellSite(t, admin, "tenant", "site", 2)
if _, err := admin.ExecContext(ctx, `UPDATE bell.areas SET capture_policy = 'non_imaging_only'
WHERE tenant_id = 'tenant' AND id = 'area-default'`); err != nil {
t.Fatal(err)
}
blocked := videoDevice(1, "tenant", "site")
blocked.DesiredState = device.DesiredDisabled
err := store.CreateDevice(ctx, blocked)
if !errors.Is(err, ErrAreaPolicyDenied) {
t.Fatalf("disabled imaging create must still be denied, got %v", err)
}
radar := device.Device{
ID: "radar-001", TenantID: "tenant", SiteID: "site", AreaID: "area-default",
SerialNumber: "radar-001", 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.Fatalf("non-imaging device must be allowed: %v", err)
}
stored, err := store.GetDevice(ctx, radar.ID)
if err != nil {
t.Fatal(err)
}
if stored.AreaID != "area-default" || stored.AreaPolicySourceVersion != 2 {
t.Fatalf("Area projection evidence was not stored: %+v", stored)
}
var devices, audits int
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.devices`).Scan(&devices); err != nil {
t.Fatal(err)
}
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.device_operation_outbox`).Scan(&audits); err != nil {
t.Fatal(err)
}
if devices != 1 || audits != 1 {
t.Fatalf("denied create left partial state: devices=%d audits=%d", devices, audits)
}
}
func TestPostgresAreaProjectionMissingAndRollbackFailClosed(t *testing.T) {
store, admin := openPostgresTestStore(t)
ctx := context.Background()
insertBellSite(t, admin, "tenant", "site", 2)
missing := videoDevice(1, "tenant", "site")
missing.AreaID = "missing-area"
if err := store.CreateDevice(ctx, missing); !errors.Is(err, ErrAreaPolicyUnavailable) {
t.Fatalf("missing Area projection must fail closed, got %v", err)
}
if _, err := store.db.ExecContext(ctx, `INSERT INTO sense.area_policy_projection_state(
tenant_id, site_id, area_id, source_version, synced_at
) VALUES ('tenant', 'site', 'area-default', 99, clock_timestamp())`); err != nil {
t.Fatal(err)
}
if err := store.CreateDevice(ctx, videoDevice(2, "tenant", "site")); !errors.Is(err, ErrAreaPolicyInvalid) {
t.Fatalf("Area source-version rollback must fail closed, got %v", err)
}
var devices, audits int
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.devices`).Scan(&devices); err != nil {
t.Fatal(err)
}
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.device_operation_outbox`).Scan(&audits); err != nil {
t.Fatal(err)
}
if devices != 0 || audits != 0 {
t.Fatalf("failed Area admissions persisted state: devices=%d audits=%d", devices, audits)
}
}
func TestPostgresConcurrentAreaObservationIsMonotonic(t *testing.T) {
store, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 2)
ctx := context.Background()
start := make(chan struct{})
errorsFound := make(chan error, 2)
var wait sync.WaitGroup
for index := 1; index <= 2; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
value := videoDevice(index, "tenant", "site")
value.DesiredState = device.DesiredDisabled
<-start
errorsFound <- store.CreateDevice(ctx, value)
}(index)
}
close(start)
wait.Wait()
close(errorsFound)
for err := range errorsFound {
if err != nil {
t.Fatalf("concurrent Area observation failed: %v", err)
}
}
var sourceVersion int64
if err := admin.QueryRowContext(ctx, `SELECT source_version
FROM sense.area_policy_projection_state
WHERE tenant_id = 'tenant' AND site_id = 'site' AND area_id = 'area-default'`).
Scan(&sourceVersion); err != nil {
t.Fatal(err)
}
if sourceVersion != 1 {
t.Fatalf("concurrent observation recorded version %d", sourceVersion)
}
}
func TestPostgresAreaProjectionCannotCrossTenantBoundary(t *testing.T) {
store, admin := openPostgresTestStore(t)
ctx := context.Background()
insertBellSite(t, admin, "tenant-a", "site", 2)
insertBellSite(t, admin, "tenant-b", "site", 2)
insertBellArea(t, admin, "tenant-b", "site", "private-area", "video_allowed")
value := videoDevice(1, "tenant-a", "site")
value.AreaID = "private-area"
if err := store.CreateDevice(ctx, value); !errors.Is(err, ErrAreaPolicyUnavailable) {
t.Fatalf("cross-tenant Area was not hidden as unavailable: %v", err)
}
}
func TestPostgresEnableRechecksAreaWithoutStoppingExistingDevice(t *testing.T) {
store, admin := openPostgresTestStore(t)
ctx := context.Background()
insertBellSite(t, admin, "tenant", "site", 2)
disabled := videoDevice(1, "tenant", "site")
disabled.DesiredState = device.DesiredDisabled
if err := store.CreateDevice(ctx, disabled); err != nil {
t.Fatal(err)
}
if err := store.CreateDevice(ctx, videoDevice(2, "tenant", "site")); err != nil {
t.Fatal(err)
}
if _, err := admin.ExecContext(ctx, `UPDATE bell.areas SET capture_policy = 'non_imaging_only'
WHERE tenant_id = 'tenant' AND id = 'area-default'`); err != nil {
t.Fatal(err)
}
if err := store.SetDesiredState(ctx, disabled.ID, device.DesiredEnabled); !errors.Is(err, ErrAreaPolicyDenied) {
t.Fatalf("enable under non-imaging policy must be denied, got %v", err)
}
stillDisabled, err := store.GetDevice(ctx, disabled.ID)
if err != nil {
t.Fatal(err)
}
stillEnabled, err := store.GetDevice(ctx, "camera-002")
if err != nil {
t.Fatal(err)
}
if stillDisabled.DesiredState != device.DesiredDisabled ||
stillEnabled.DesiredState != device.DesiredEnabled {
t.Fatalf("policy change altered existing state: disabled=%s enabled=%s",
stillDisabled.DesiredState, stillEnabled.DesiredState)
}
}
func TestPostgresAuditContextRedactionAndNoopDesiredState(t *testing.T) {
store, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 2)
ctx := WithAuditContext(context.Background(), AuditContext{
ActorType: AuditActorUser, ActorID: "operator-7", Reason: "approved change", TraceID: "trace-7",
})
value := videoDevice(1, "tenant", "site")
if err := store.CreateDevice(ctx, value); err != nil {
t.Fatal(err)
}
if err := store.SetDesiredState(ctx, value.ID, device.DesiredEnabled); err != nil {
t.Fatal(err)
}
stored, err := store.GetDevice(ctx, value.ID)
if err != nil {
t.Fatal(err)
}
if stored.Generation != 1 {
t.Fatalf("no-op desired-state request changed generation to %d", stored.Generation)
}
rows, err := admin.QueryContext(ctx, `SELECT event_id, event_type, actor_type, actor_id,
COALESCE(reason, ''), COALESCE(trace_id, ''), payload::text
FROM sense.device_operation_outbox ORDER BY occurred_at, event_id`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var count int
for rows.Next() {
var eventID, eventType, actorType, actorID, reason, traceID, payload string
if err := rows.Scan(&eventID, &eventType, &actorType, &actorID, &reason, &traceID, &payload); err != nil {
t.Fatal(err)
}
count++
if !strings.HasPrefix(eventID, "audit_") || len(eventID) != 38 {
t.Fatalf("invalid audit event ID %q", eventID)
}
if actorType != "user" || actorID != "operator-7" || reason != "approved change" || traceID != "trace-7" {
t.Fatalf("audit principal/context drift: %s/%s %s %s", actorType, actorID, reason, traceID)
}
for _, secret := range []string{value.EndpointRef, value.CredentialRef, value.PathName} {
if strings.Contains(payload, secret) {
t.Fatalf("audit payload leaked sensitive runtime data for %s", eventType)
}
}
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("expected create and no-op audit facts, got %d", count)
}
}
func TestPostgresOutboxFailureRollsBackAdmission(t *testing.T) {
store, admin := openPostgresTestStore(t)
ctx := context.Background()
insertBellSite(t, admin, "tenant", "site", 2)
if _, err := admin.ExecContext(ctx, `CREATE FUNCTION sense.t010_reject_outbox()
RETURNS trigger LANGUAGE plpgsql AS $function$
BEGIN RAISE EXCEPTION 'synthetic outbox failure'; END
$function$;
CREATE TRIGGER t010_reject_outbox BEFORE INSERT ON sense.device_operation_outbox
FOR EACH ROW EXECUTE FUNCTION sense.t010_reject_outbox()`); err != nil {
t.Fatal(err)
}
defer func() {
_, _ = admin.ExecContext(context.Background(),
`DROP TRIGGER IF EXISTS t010_reject_outbox ON sense.device_operation_outbox;
DROP FUNCTION IF EXISTS sense.t010_reject_outbox()`)
}()
if err := store.CreateDevice(ctx, videoDevice(1, "tenant", "site")); err == nil {
t.Fatal("synthetic Outbox failure did not reject device creation")
}
var devices, projections, audits int
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.devices`).Scan(&devices); err != nil {
t.Fatal(err)
}
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.area_policy_projection_state`).Scan(&projections); err != nil {
t.Fatal(err)
}
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.device_operation_outbox`).Scan(&audits); err != nil {
t.Fatal(err)
}
if devices != 0 || projections != 0 || audits != 0 {
t.Fatalf("Outbox failure left partial transaction: devices=%d projections=%d audits=%d",
devices, projections, audits)
}
}
func TestPostgresOutboxFailureRollsBackDesiredState(t *testing.T) {
store, admin := openPostgresTestStore(t)
ctx := context.Background()
insertBellSite(t, admin, "tenant", "site", 2)
value := videoDevice(1, "tenant", "site")
value.DesiredState = device.DesiredDisabled
if err := store.CreateDevice(ctx, value); err != nil {
t.Fatal(err)
}
if _, err := admin.ExecContext(ctx, `CREATE FUNCTION sense.t010_reject_state_audit()
RETURNS trigger LANGUAGE plpgsql AS $function$
BEGIN RAISE EXCEPTION 'synthetic state-audit failure'; END
$function$;
CREATE TRIGGER t010_reject_state_audit BEFORE INSERT ON sense.device_operation_outbox
FOR EACH ROW EXECUTE FUNCTION sense.t010_reject_state_audit()`); err != nil {
t.Fatal(err)
}
defer func() {
_, _ = admin.ExecContext(context.Background(),
`DROP TRIGGER IF EXISTS t010_reject_state_audit ON sense.device_operation_outbox;
DROP FUNCTION IF EXISTS sense.t010_reject_state_audit()`)
}()
if err := store.SetDesiredState(ctx, value.ID, device.DesiredEnabled); err == nil {
t.Fatal("synthetic Outbox failure did not reject desired-state change")
}
stored, err := store.GetDevice(ctx, value.ID)
if err != nil {
t.Fatal(err)
}
if stored.DesiredState != device.DesiredDisabled || stored.Generation != 1 {
t.Fatalf("Outbox failure committed desired state: %+v", stored)
}
var audits int
if err := admin.QueryRowContext(ctx, `SELECT count(*) FROM sense.device_operation_outbox`).Scan(&audits); err != nil {
t.Fatal(err)
}
if audits != 1 {
t.Fatalf("failed desired-state transaction changed Outbox count to %d", audits)
}
}
func TestPostgresOpenRejectsAreaSourcePrivilege(t *testing.T) {
_, admin := openPostgresTestStore(t)
ctx := context.Background()
if _, err := admin.ExecContext(ctx,
`GRANT SELECT ON bell.areas TO yovision_t012_sense`); err != nil {
t.Fatal(err)
}
defer func() {
_, _ = admin.ExecContext(context.Background(),
`REVOKE SELECT ON bell.areas FROM yovision_t012_sense`)
}()
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
if value != nil {
_ = value.Close()
t.Fatal("Area-source privilege was accepted")
}
if err == nil || !strings.Contains(err.Error(), "privilege boundary") {
t.Fatalf("expected Area privilege-boundary error, got %v", err)
}
}
func TestPostgresOpenRejectsPublicControlStatePrivilege(t *testing.T) {
_, admin := openPostgresTestStore(t)
ctx := context.Background()
if _, err := admin.ExecContext(ctx, `GRANT SELECT ON sense.batch_operations TO PUBLIC`); err != nil {
t.Fatal(err)
}
defer func() {
_, _ = admin.ExecContext(context.Background(), `REVOKE SELECT ON sense.batch_operations FROM PUBLIC`)
}()
value, err := OpenPostgres(ctx, os.Getenv(postgresTestDSNEnv))
if value != nil {
_ = value.Close()
t.Fatal("PUBLIC Control API table privilege was accepted")
}
if err == nil || !strings.Contains(err.Error(), "Control API state privilege boundary") {
t.Fatalf("expected Control API privilege-boundary error, got %v", err)
}
}
func TestPostgresControlCreateIdempotencyAndRedactedSnapshot(t *testing.T) {
postgres, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 2)
value := videoDevice(1, "tenant", "site")
value.ID = "dev_control_create_1"
value.DesiredState = device.DesiredDisabled
hash := sha256.Sum256([]byte("canonical-create"))
ctx := WithAuditContext(context.Background(), AuditContext{
ActorType: AuditActorService, ActorID: "bell-control", TraceID: "trace-control-create",
})
request := ControlCreateRequest{
Scope: IdempotencyScope{
PrincipalID: "bell-control", TenantID: "tenant", SiteID: "site",
Operation: "createDevice", Key: "create-control-0001",
RequestHash: hash, TraceID: "trace-control-create",
},
Device: value,
}
first, err := postgres.CreateControlDevice(ctx, request)
if err != nil {
t.Fatal(err)
}
request.Device.ID = "dev_control_create_retry"
replayed, err := postgres.CreateControlDevice(ctx, request)
if err != nil {
t.Fatal(err)
}
if !replayed.Replay || replayed.Device.ID != first.Device.ID || replayed.TraceID != first.TraceID {
t.Fatalf("create replay drifted: first=%+v replay=%+v", first, replayed)
}
conflictHash := sha256.Sum256([]byte("different-create"))
request.Scope.RequestHash = conflictHash
if _, err := postgres.CreateControlDevice(ctx, request); !errors.Is(err, ErrIdempotencyConflict) {
t.Fatalf("same key with different body was not rejected: %v", err)
}
var devices, receipts int
var responseBody string
if err := admin.QueryRow(`SELECT count(*) FROM sense.devices`).Scan(&devices); err != nil {
t.Fatal(err)
}
if err := admin.QueryRow(`SELECT count(*), min(response_body::text)
FROM sense.control_idempotency_receipts`).Scan(&receipts, &responseBody); err != nil {
t.Fatal(err)
}
if devices != 1 || receipts != 1 {
t.Fatalf("idempotent create counts drifted: devices=%d receipts=%d", devices, receipts)
}
for _, forbidden := range []string{value.EndpointRef, value.CredentialRef, "profile_token", "path_name"} {
if strings.Contains(responseBody, forbidden) {
t.Fatalf("idempotency response snapshot leaked %q", forbidden)
}
}
}
func TestPostgresControlListIsStableFilteredAndTenantScoped(t *testing.T) {
postgres, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 4)
insertBellSite(t, admin, "other", "site", 4)
for index := 1; index <= 3; index++ {
value := videoDevice(index, "tenant", "site")
value.ID = fmt.Sprintf("dev_list_%d", index)
value.DesiredState = device.DesiredDisabled
if index == 3 {
value.DesiredState = device.DesiredEnabled
}
if err := postgres.CreateDevice(context.Background(), value); err != nil {
t.Fatal(err)
}
}
other := videoDevice(9, "other", "site")
other.ID, other.DesiredState = "dev_list_other", device.DesiredDisabled
if err := postgres.CreateDevice(context.Background(), other); err != nil {
t.Fatal(err)
}
first, err := postgres.ListControlDevices(context.Background(), "tenant", "site", ControlListFilter{Limit: 1})
if err != nil || len(first.Items) != 1 || !first.HasMore || first.Quota.UsedVideoChannels != 1 {
t.Fatalf("unexpected first page: %+v %v", first, err)
}
second, err := postgres.ListControlDevices(context.Background(), "tenant", "site", ControlListFilter{
Limit: 1, AfterCreated: &first.Items[0].CreatedAt, AfterDeviceID: first.Items[0].ID,
})
if err != nil || len(second.Items) != 1 || second.Items[0].ID == first.Items[0].ID {
t.Fatalf("stable cursor position failed: %+v %v", second, err)
}
desired := device.DesiredEnabled
filtered, err := postgres.ListControlDevices(context.Background(), "tenant", "site", ControlListFilter{
Limit: 100, DesiredState: &desired,
})
if err != nil || len(filtered.Items) != 1 || filtered.Items[0].ID != "dev_list_3" {
t.Fatalf("desired-state filter or tenant scope failed: %+v %v", filtered, err)
}
if _, err := postgres.ListControlDevices(context.Background(), "tenant", "missing", ControlListFilter{Limit: 50}); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing Site did not return not found: %v", err)
}
}
func TestPostgresControlPatchUsesETagAndAuditV2(t *testing.T) {
postgres, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 2)
value := videoDevice(1, "tenant", "site")
value.ID = "dev_control_patch_1"
value.DesiredState = device.DesiredDisabled
if err := postgres.CreateDevice(context.Background(), value); err != nil {
t.Fatal(err)
}
current, err := postgres.GetControlDevice(context.Background(), "tenant", "site", value.ID)
if err != nil {
t.Fatal(err)
}
etag := DeviceETag(current.ID, current.ResourceVersion)
newName, newProfile := "Updated camera", "profile-main"
ctx := WithAuditContext(context.Background(), AuditContext{
ActorType: AuditActorUser, ActorID: "operator-1", TraceID: "trace-control-patch",
})
updated, err := postgres.PatchControlDevice(ctx, "tenant", "site", value.ID, etag, ControlPatch{
Name: &newName, ProfileToken: &newProfile,
})
if err != nil {
t.Fatal(err)
}
if updated.Device.ResourceVersion != current.ResourceVersion+1 ||
updated.Device.Generation != current.Generation+1 || updated.ETag == etag {
t.Fatalf("patch did not advance versions: before=%+v after=%+v", current, updated)
}
if _, err := postgres.PatchControlDevice(ctx, "tenant", "site", value.ID, etag, ControlPatch{Name: &newName}); !errors.Is(err, ErrETagMismatch) {
t.Fatalf("stale ETag was accepted: %v", err)
}
var profileToken, eventType, payload string
if err := admin.QueryRow(`SELECT profile_token FROM sense.devices WHERE id = $1`, value.ID).Scan(&profileToken); err != nil {
t.Fatal(err)
}
if err := admin.QueryRow(`SELECT event_type, payload::text
FROM sense.device_operation_outbox WHERE event_type = 'device.configuration.accepted'`).
Scan(&eventType, &payload); err != nil {
t.Fatal(err)
}
if profileToken != newProfile || eventType != "device.configuration.accepted" ||
strings.Contains(payload, newProfile) || !strings.Contains(payload, "profile_token") {
t.Fatalf("configuration persistence/audit mismatch: profile=%q event=%q payload=%s", profileToken, eventType, payload)
}
}
func TestPostgresControlBatchIsPerItemDurableAndReplayable(t *testing.T) {
postgres, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 4)
first := videoDevice(1, "tenant", "site")
first.ID, first.DesiredState = "dev_batch_1", device.DesiredDisabled
second := videoDevice(2, "tenant", "site")
second.ID, second.DesiredState = "dev_batch_2", device.DesiredDisabled
for _, value := range []device.Device{first, second} {
if err := postgres.CreateDevice(context.Background(), value); err != nil {
t.Fatal(err)
}
}
hash := sha256.Sum256([]byte("canonical-batch"))
ctx := WithAuditContext(context.Background(), AuditContext{
ActorType: AuditActorUser, ActorID: "operator-1", Reason: "approved", TraceID: "trace-batch",
})
request := ControlBatchRequest{
Scope: IdempotencyScope{
PrincipalID: "operator-1", TenantID: "tenant", SiteID: "site",
Operation: "batchSetDeviceDesiredState", Key: "batch-control-0001",
RequestHash: hash, TraceID: "trace-batch",
},
Reason: "approved",
Items: []ControlBatchItem{
{DeviceID: first.ID, ETag: DeviceETag(first.ID, 1), DesiredState: device.DesiredEnabled},
{DeviceID: first.ID, ETag: DeviceETag(first.ID, 1), DesiredState: device.DesiredEnabled},
{DeviceID: second.ID, ETag: DeviceETag(second.ID, 1), DesiredState: device.DesiredEnabled},
},
}
operation, err := postgres.BatchSetControlDesiredState(ctx, request)
if err != nil {
t.Fatal(err)
}
if operation.Status != "partially_succeeded" || len(operation.Results) != 3 ||
operation.Results[0].Status != "rejected" || operation.Results[1].Status != "rejected" ||
operation.Results[2].Status != "succeeded" {
t.Fatalf("unexpected batch result: %+v", operation)
}
replayed, err := postgres.BatchSetControlDesiredState(ctx, request)
if err != nil || !replayed.Replay || replayed.ID != operation.ID {
t.Fatalf("batch replay drifted: %+v %v", replayed, err)
}
read, err := postgres.GetControlOperation(context.Background(), "tenant", operation.ID)
if err != nil || read.SiteID != "site" || len(read.Results) != 3 {
t.Fatalf("stored operation could not be read: %+v %v", read, err)
}
if _, err := postgres.GetControlOperation(context.Background(), "other-tenant", operation.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("cross-tenant operation was visible: %v", err)
}
var enabled, operations, receipts int
if err := admin.QueryRow(`SELECT count(*) FROM sense.devices WHERE desired_state = 'enabled'`).Scan(&enabled); err != nil {
t.Fatal(err)
}
if err := admin.QueryRow(`SELECT count(*) FROM sense.batch_operations`).Scan(&operations); err != nil {
t.Fatal(err)
}
if err := admin.QueryRow(`SELECT count(*) FROM sense.control_idempotency_receipts`).Scan(&receipts); err != nil {
t.Fatal(err)
}
if enabled != 1 || operations != 1 || receipts != 1 {
t.Fatalf("batch durability counts drifted: enabled=%d operations=%d receipts=%d", enabled, operations, receipts)
}
}
func TestPostgresConcurrentControlCreateExecutesOnce(t *testing.T) {
postgres, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 2)
hash := sha256.Sum256([]byte("concurrent-create"))
start := make(chan struct{})
results := make(chan ControlCreateResult, 2)
errorsFound := make(chan error, 2)
var wait sync.WaitGroup
for index := 1; index <= 2; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
value := videoDevice(index, "tenant", "site")
value.ID = fmt.Sprintf("dev_concurrent_%d", index)
value.SerialNumber = "same-semantic-serial"
value.DesiredState = device.DesiredDisabled
request := ControlCreateRequest{
Scope: IdempotencyScope{
PrincipalID: "service", TenantID: "tenant", SiteID: "site",
Operation: "createDevice", Key: "concurrent-create-0001",
RequestHash: hash, TraceID: fmt.Sprintf("trace-concurrent-%d", index),
}, Device: value,
}
<-start
result, err := postgres.CreateControlDevice(context.Background(), request)
if err != nil {
errorsFound <- err
return
}
results <- result
}(index)
}
close(start)
wait.Wait()
close(results)
close(errorsFound)
for err := range errorsFound {
t.Fatalf("concurrent idempotent create failed: %v", err)
}
ids := make(map[string]struct{})
for result := range results {
ids[result.Device.ID] = struct{}{}
}
if len(ids) != 1 {
t.Fatalf("concurrent create returned multiple resources: %+v", ids)
}
var devices, audits, receipts int
if err := admin.QueryRow(`SELECT count(*) FROM sense.devices`).Scan(&devices); err != nil {
t.Fatal(err)
}
if err := admin.QueryRow(`SELECT count(*) FROM sense.device_operation_outbox`).Scan(&audits); err != nil {
t.Fatal(err)
}
if err := admin.QueryRow(`SELECT count(*) FROM sense.control_idempotency_receipts`).Scan(&receipts); err != nil {
t.Fatal(err)
}
if devices != 1 || audits != 1 || receipts != 1 {
t.Fatalf("concurrent create executed more than once: devices=%d audits=%d receipts=%d", devices, audits, receipts)
}
}
func TestPostgresConcurrentControlBatchesUseStableDeviceLockOrder(t *testing.T) {
postgres, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 4)
ids := []string{"dev_lock_a", "dev_lock_b"}
for index, id := range ids {
value := videoDevice(index+1, "tenant", "site")
value.ID, value.DesiredState = id, device.DesiredDisabled
if err := postgres.CreateDevice(context.Background(), value); err != nil {
t.Fatal(err)
}
}
start := make(chan struct{})
operations := make(chan ControlBatchOperation, 2)
errorsFound := make(chan error, 2)
var wait sync.WaitGroup
for index := 0; index < 2; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
order := ids
if index == 1 {
order = []string{ids[1], ids[0]}
}
hash := sha256.Sum256([]byte(fmt.Sprintf("batch-order-%d", index)))
request := ControlBatchRequest{
Scope: IdempotencyScope{
PrincipalID: "operator", TenantID: "tenant", SiteID: "site",
Operation: "batchSetDeviceDesiredState",
Key: fmt.Sprintf("batch-lock-order-%04d", index), RequestHash: hash,
TraceID: fmt.Sprintf("trace-lock-order-%d", index),
},
Reason: "concurrency test",
Items: []ControlBatchItem{
{DeviceID: order[0], ETag: DeviceETag(order[0], 1), DesiredState: device.DesiredEnabled},
{DeviceID: order[1], ETag: DeviceETag(order[1], 1), DesiredState: device.DesiredEnabled},
},
}
<-start
operation, err := postgres.BatchSetControlDesiredState(context.Background(), request)
if err != nil {
errorsFound <- err
return
}
operations <- operation
}(index)
}
close(start)
wait.Wait()
close(operations)
close(errorsFound)
for err := range errorsFound {
t.Fatalf("opposite-order batch failed or deadlocked: %v", err)
}
var succeeded, failed int
for operation := range operations {
switch operation.Status {
case "succeeded":
succeeded++
case "failed":
failed++
default:
t.Fatalf("unexpected concurrent batch status: %+v", operation)
}
}
if succeeded != 1 || failed != 1 {
t.Fatalf("expected one winner and one stale loser, got succeeded=%d failed=%d", succeeded, failed)
}
}
func TestPostgresConcurrentReconcileClaimHasOneWinner(t *testing.T) {
first, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 2)
if err := first.CreateDevice(context.Background(), videoDevice(1, "tenant", "site")); err != nil {
t.Fatal(err)
}
second, err := OpenPostgres(context.Background(), os.Getenv(postgresTestDSNEnv))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = second.Close() })
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
start := make(chan struct{})
counts := make(chan int, 2)
errorsFound := make(chan error, 2)
var wait sync.WaitGroup
for index, repository := range []*Postgres{first, second} {
wait.Add(1)
go func(index int, repository *Postgres) {
defer wait.Done()
<-start
values, err := repository.ClaimDueReconcile(context.Background(), ReconcileClaim{
Owner: fmt.Sprintf("ins-%d", index), Token: fmt.Sprintf("token-%d", index),
Now: now, LeaseDuration: 30 * time.Second, Limit: 1,
})
if err != nil {
errorsFound <- err
return
}
counts <- len(values)
}(index, repository)
}
close(start)
wait.Wait()
close(counts)
close(errorsFound)
for err := range errorsFound {
t.Fatal(err)
}
total, winners := 0, 0
for count := range counts {
total += count
if count == 1 {
winners++
}
}
if total != 1 || winners != 1 {
t.Fatalf("due row was not exclusively claimed: total=%d winners=%d", total, winners)
}
}
func TestPostgresExpiredReconcileLeaseFencesOldWorkerAndRecordsOwnership(t *testing.T) {
postgres, admin := openPostgresTestStore(t)
insertBellSite(t, admin, "tenant", "site", 2)
value := videoDevice(1, "tenant", "site")
if err := postgres.CreateDevice(context.Background(), value); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
first, err := postgres.ClaimDueReconcile(context.Background(), ReconcileClaim{
Owner: "ins-a", Token: "token-a", Now: now, LeaseDuration: 30 * time.Second, Limit: 1,
})
if err != nil || len(first) != 1 {
t.Fatalf("first claim failed: %+v %v", first, err)
}
early, err := postgres.ClaimDueReconcile(context.Background(), ReconcileClaim{
Owner: "ins-b", Token: "token-b", Now: now.Add(10 * time.Second),
LeaseDuration: 30 * time.Second, Limit: 1,
})
if err != nil || len(early) != 0 {
t.Fatalf("live lease was stolen: %+v %v", early, err)
}
if _, err := admin.Exec(`UPDATE sense.reconcile_state
SET lease_until = clock_timestamp() - interval '1 second'
WHERE device_id = $1`, value.ID); err != nil {
t.Fatal(err)
}
second, err := postgres.ClaimDueReconcile(context.Background(), ReconcileClaim{
Owner: "ins-b", Token: "token-b", Now: now.Add(31 * time.Second),
LeaseDuration: 30 * time.Second, Limit: 1,
})
if err != nil || len(second) != 1 {
t.Fatalf("expired lease was not recoverable: %+v %v", second, err)
}
if err := postgres.CompleteReconcile(
context.Background(), value.ID, value.Generation, "ins-a", "token-a", now.Add(32*time.Second),
); !errors.Is(err, ErrReconcileLeaseLost) {
t.Fatalf("old worker was not fenced: %v", err)
}
if err := postgres.CompleteReconcile(
context.Background(), value.ID, value.Generation, "ins-b", "token-b", now.Add(32*time.Second),
); err != nil {
t.Fatal(err)
}
var ownershipDevice string
if err := admin.QueryRow(`SELECT device_id FROM sense.media_path_ownership WHERE path_name = $1`,
value.PathName).Scan(&ownershipDevice); err != nil {
t.Fatal(err)
}
if ownershipDevice != value.ID {
t.Fatalf("wrong ownership was recorded: %q", ownershipDevice)
}
var leaseToken sql.NullString
if err := admin.QueryRow(`SELECT lease_token FROM sense.reconcile_state WHERE device_id = $1`,
value.ID).Scan(&leaseToken); err != nil {
t.Fatal(err)
}
if leaseToken.Valid {
t.Fatal("completion did not release the reconcile lease")
}
}
func TestPostgresOrphanReportLeaseAndCleanupAuditAreFencedAndIdempotent(t *testing.T) {
postgres, _ := openPostgresTestStore(t)
ctx := context.Background()
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
acquired, err := postgres.AcquireOperationalLease(
ctx, OperationalLeaseOrphanScan, "ins-a", "scan-token-a", now, 30*time.Second,
)
if err != nil || !acquired {
t.Fatalf("scan lease failed: %v %v", acquired, err)
}
scan := OrphanScan{
ID: "scan_" + strings.Repeat("0", 26), InstanceID: "ins-a",
ObservedCount: 10, OwnedStaleCount: 1, UnownedCount: 1,
SafetyAllowed: true, SafetyReason: "allowed",
CompletedAt: now.Add(time.Second), ExpiresAt: now.Add(15 * time.Minute),
Findings: []OrphanFinding{
{PathName: "stale", Classification: OrphanOwnedStale, DeviceID: "old-device"},
{PathName: "unknown", Classification: OrphanUnowned},
},
}
if err := postgres.SaveOrphanScan(ctx, scan, "ins-a", "scan-token-a"); err != nil {
t.Fatal(err)
}
loaded, err := postgres.GetOrphanScan(ctx, scan.ID)
if err != nil || len(loaded.Findings) != 2 || !loaded.SafetyAllowed {
t.Fatalf("stored scan mismatch: %+v %v", loaded, err)
}
if err := postgres.RecordOrphanCleanup(
ctx, scan.ID, "stale", "operator", "deleted", "", now.Add(2*time.Second),
); err != nil {
t.Fatal(err)
}
if err := postgres.RecordOrphanCleanup(
ctx, scan.ID, "unknown", "operator", "deleted", "", now.Add(2*time.Second),
); err == nil {
t.Fatal("unowned path accepted a cleanup audit record")
}
if err := postgres.RecordOrphanCleanup(
ctx, scan.ID, "stale", "operator-2", "failed", "media_error", now.Add(3*time.Second),
); err != nil {
t.Fatal(err)
}
loaded, err = postgres.GetOrphanScan(ctx, scan.ID)
if err != nil || !loaded.Findings[0].Deleted {
t.Fatalf("successful cleanup was downgraded: %+v %v", loaded, err)
}
acquired, err = postgres.AcquireOperationalLease(
ctx, OperationalLeaseOrphanScan, "ins-b", "scan-token-b", now.Add(31*time.Second), 30*time.Second,
)
if err != nil || !acquired {
t.Fatalf("expired scan lease was not recoverable: %v %v", acquired, err)
}
staleScan := scan
staleScan.ID = "scan_" + strings.Repeat("1", 26)
staleScan.CompletedAt = now.Add(32 * time.Second)
staleScan.ExpiresAt = staleScan.CompletedAt.Add(15 * time.Minute)
if err := postgres.SaveOrphanScan(ctx, staleScan, "ins-a", "scan-token-a"); !errors.Is(err, ErrOperationalLeaseLost) {
t.Fatalf("stale scan worker was not fenced: %v", err)
}
}
func openPostgresTestStore(t *testing.T) (*Postgres, *sql.DB) { func openPostgresTestStore(t *testing.T) (*Postgres, *sql.DB) {
t.Helper() t.Helper()
dsn := os.Getenv(postgresTestDSNEnv) dsn := os.Getenv(postgresTestDSNEnv)
@@ -318,10 +1180,21 @@ func openPostgresTestStore(t *testing.T) (*Postgres, *sql.DB) {
t.Fatal("connect PostgreSQL test administrator") t.Fatal("connect PostgreSQL test administrator")
} }
if _, err := admin.ExecContext(context.Background(), `TRUNCATE if _, err := admin.ExecContext(context.Background(), `TRUNCATE
sense.orphan_cleanup_actions,
sense.orphan_scan_findings,
sense.orphan_scan_runs,
sense.operational_leases,
sense.media_path_ownership,
sense.control_idempotency_receipts,
sense.batch_operation_items,
sense.batch_operations,
sense.device_operation_outbox,
sense.device_capabilities, sense.device_capabilities,
sense.reconcile_state, sense.reconcile_state,
sense.devices, sense.devices,
sense.site_quota_projection_state, sense.site_quota_projection_state,
sense.area_policy_projection_state,
bell.areas,
bell.sites CASCADE`); err != nil { bell.sites CASCADE`); err != nil {
admin.Close() admin.Close()
t.Fatal(err) t.Fatal(err)
@@ -350,4 +1223,18 @@ func insertBellSite(t *testing.T, admin *sql.DB, tenantID, siteID string, quota
if _, err := admin.ExecContext(context.Background(), query, arguments...); err != nil { if _, err := admin.ExecContext(context.Background(), query, arguments...); err != nil {
t.Fatal(err) t.Fatal(err)
} }
insertBellArea(t, admin, tenantID, siteID, "area-default", "video_allowed")
}
func insertBellArea(
t *testing.T,
admin *sql.DB,
tenantID, siteID, areaID, capturePolicy string,
) {
t.Helper()
if _, err := admin.ExecContext(context.Background(), `INSERT INTO bell.areas(
tenant_id, site_id, id, name, capture_policy
) VALUES ($1, $2, $3, $4, $5)`, tenantID, siteID, areaID, "Test Area", capturePolicy); err != nil {
t.Fatal(err)
}
} }
+39
View File
@@ -18,6 +18,11 @@ const (
var ( var (
ErrQuotaProjectionUnavailable = errors.New("quota projection unavailable") ErrQuotaProjectionUnavailable = errors.New("quota projection unavailable")
ErrQuotaProjectionInvalid = errors.New("quota projection invalid") 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 // Repository is the storage boundary used by the Sense process. SQLite stays
@@ -28,6 +33,10 @@ type Repository interface {
SetDesiredState(context.Context, string, device.DesiredState) error SetDesiredState(context.Context, string, device.DesiredState) error
GetDevice(context.Context, string) (device.Device, error) GetDevice(context.Context, string) (device.Device, error)
ListDueReconcile(context.Context, time.Time, int) ([]ReconcileCandidate, 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) ListEnabledVideoDevices(context.Context, int) ([]device.Device, error)
MarkReconciled(context.Context, string, int64, time.Time) error MarkReconciled(context.Context, string, int64, time.Time) error
MarkReconcileFailure(context.Context, string, int, time.Time, string, time.Time) error MarkReconcileFailure(context.Context, string, int, time.Time, string, time.Time) error
@@ -36,6 +45,17 @@ type Repository interface {
ConvergenceSnapshot(context.Context) (ConvergenceSnapshot, 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) { func OpenRepository(ctx context.Context, driver, dsn string) (Repository, error) {
switch strings.ToLower(strings.TrimSpace(driver)) { switch strings.ToLower(strings.TrimSpace(driver)) {
case "", DriverSQLite: case "", DriverSQLite:
@@ -61,3 +81,22 @@ func projectionUnavailable() error {
func projectionInvalid() error { func projectionInvalid() error {
return &quotaProjectionError{kind: ErrQuotaProjectionInvalid} return &quotaProjectionError{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}
}
+45 -4
View File
@@ -366,12 +366,12 @@ func (s *SQLite) ListDueReconcile(ctx context.Context, now time.Time, limit int)
rows, err := s.db.QueryContext(ctx, `SELECT `+deviceColumns+`, r.failure_count, r.next_attempt_at rows, err := s.db.QueryContext(ctx, `SELECT `+deviceColumns+`, r.failure_count, r.next_attempt_at
FROM sense_devices d FROM sense_devices d
JOIN sense_reconcile_state r ON r.device_id = d.id JOIN sense_reconcile_state r ON r.device_id = d.id
WHERE d.desired_state = 'enabled' WHERE EXISTS (
AND EXISTS (
SELECT 1 FROM sense_device_capabilities c SELECT 1 FROM sense_device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture' WHERE c.device_id = d.id AND c.capability = 'video_capture'
) )
AND (r.observed_generation < d.generation OR r.failure_count > 0) 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 <= ?) AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= ?)
ORDER BY d.updated_at, d.id ORDER BY d.updated_at, d.id
LIMIT ?`, formatTime(now), limit) LIMIT ?`, formatTime(now), limit)
@@ -430,6 +430,24 @@ func (s *SQLite) ListDueReconcile(ctx context.Context, now time.Time, limit int)
return candidates, nil 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) { func (s *SQLite) ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error) {
if limit <= 0 { if limit <= 0 {
return nil, nil return nil, nil
@@ -487,7 +505,9 @@ func (s *SQLite) MarkReconciled(ctx context.Context, id string, generation int64
return ErrNotFound return ErrNotFound
} }
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
UPDATE sense_devices SET actual_state = 'pending', updated_at = ? WHERE id = ?`, 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 { formatTime(now), id); err != nil {
return fmt.Errorf("mark reconciled device pending: %w", err) return fmt.Errorf("mark reconciled device pending: %w", err)
} }
@@ -497,6 +517,16 @@ func (s *SQLite) MarkReconciled(ctx context.Context, id string, generation int64
return nil 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 { 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) tx, err := s.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
@@ -524,6 +554,17 @@ func (s *SQLite) MarkReconcileFailure(ctx context.Context, id string, failureCou
return nil 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 { 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 { if state != device.ActualPending && state != device.ActualOnline && state != device.ActualOffline && state != device.ActualFailed {
return fmt.Errorf("invalid actual state %q", state) return fmt.Errorf("invalid actual state %q", state)
+2 -1
View File
@@ -195,7 +195,8 @@ func openTestStore(t *testing.T) *SQLite {
func videoDevice(index int, tenantID, siteID string) device.Device { func videoDevice(index int, tenantID, siteID string) device.Device {
id := fmt.Sprintf("camera-%03d", index) id := fmt.Sprintf("camera-%03d", index)
return device.Device{ return device.Device{
ID: id, TenantID: tenantID, SiteID: siteID, SerialNumber: id, Name: id, ID: id, TenantID: tenantID, SiteID: siteID, AreaID: "area-default",
SerialNumber: id, Name: id,
Modality: device.ModalityVideo, Modality: device.ModalityVideo,
Capabilities: []device.Capability{device.CapabilityVideoCapture, device.CapabilitySpatialRule}, Capabilities: []device.Capability{device.CapabilityVideoCapture, device.CapabilitySpatialRule},
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending, DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
+973
View File
@@ -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
+127
View File
@@ -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;
+149
View File
@@ -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;
+147
View File
@@ -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;
+115
View File
@@ -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;
+130
View File
@@ -0,0 +1,130 @@
-- Sense Outbox relay fencing and Bell global audit facts.
ALTER TABLE sense.device_operation_outbox
ADD COLUMN IF NOT EXISTS relay_lease_owner text,
ADD COLUMN IF NOT EXISTS relay_lease_token bigint NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS relay_lease_until timestamptz,
ADD COLUMN IF NOT EXISTS last_error_code text,
ADD COLUMN IF NOT EXISTS dead_lettered_at timestamptz;
ALTER TABLE sense.device_operation_outbox
DROP CONSTRAINT IF EXISTS sense_outbox_relay_lease_pair;
ALTER TABLE sense.device_operation_outbox
ADD CONSTRAINT sense_outbox_relay_lease_pair CHECK (
(relay_lease_owner IS NULL AND relay_lease_until IS NULL)
OR (relay_lease_owner IS NOT NULL AND btrim(relay_lease_owner) <> ''
AND relay_lease_until IS NOT NULL)
);
ALTER TABLE sense.device_operation_outbox
DROP CONSTRAINT IF EXISTS sense_outbox_relay_token_nonnegative;
ALTER TABLE sense.device_operation_outbox
ADD CONSTRAINT sense_outbox_relay_token_nonnegative CHECK (relay_lease_token >= 0);
ALTER TABLE sense.device_operation_outbox
DROP CONSTRAINT IF EXISTS sense_outbox_relay_error_length;
ALTER TABLE sense.device_operation_outbox
ADD CONSTRAINT sense_outbox_relay_error_length CHECK (
last_error_code IS NULL OR (
char_length(last_error_code) BETWEEN 1 AND 64
AND last_error_code ~ '^[a-z][a-z0-9_]*$'
)
);
ALTER TABLE sense.device_operation_outbox
DROP CONSTRAINT IF EXISTS sense_outbox_relay_terminal_state;
ALTER TABLE sense.device_operation_outbox
ADD CONSTRAINT sense_outbox_relay_terminal_state CHECK (
delivered_at IS NULL OR dead_lettered_at IS NULL
);
CREATE INDEX IF NOT EXISTS sense_outbox_relay_due_idx
ON sense.device_operation_outbox(
COALESCE(next_attempt_at, available_at), event_id
)
WHERE delivered_at IS NULL AND dead_lettered_at IS NULL;
CREATE TABLE IF NOT EXISTS bell.audit_events (
source_system text NOT NULL,
event_id text NOT NULL,
schema_version smallint NOT NULL,
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,
received_at timestamptz NOT NULL DEFAULT clock_timestamp(),
record_hash bytea NOT NULL,
PRIMARY KEY (source_system, event_id),
CONSTRAINT bell_audit_source_system CHECK (source_system = 'sense'),
CONSTRAINT bell_audit_event_id CHECK (event_id ~ '^audit_[0-9a-f]{32}$'),
CONSTRAINT bell_audit_schema_version CHECK (schema_version IN (1, 2)),
CONSTRAINT bell_audit_event_type CHECK (event_type IN (
'device.created',
'device.desired_state.accepted',
'device.configuration.accepted'
)),
CONSTRAINT bell_audit_identity_not_blank CHECK (
btrim(tenant_id) <> '' AND btrim(site_id) <> '' AND btrim(device_id) <> ''
AND btrim(actor_id) <> ''
),
CONSTRAINT bell_audit_actor_type CHECK (actor_type IN ('user', 'service', 'system')),
CONSTRAINT bell_audit_generation_positive CHECK (aggregate_generation >= 1),
CONSTRAINT bell_audit_projection_versions CHECK (
(quota_source_version IS NULL OR quota_source_version >= 1)
AND (area_policy_source_version IS NULL OR area_policy_source_version >= 1)
),
CONSTRAINT bell_audit_reason_length CHECK (reason IS NULL OR char_length(reason) <= 500),
CONSTRAINT bell_audit_trace_length CHECK (trace_id IS NULL OR char_length(trace_id) <= 128),
CONSTRAINT bell_audit_payload_object CHECK (jsonb_typeof(payload) = 'object'),
CONSTRAINT bell_audit_record_hash_length CHECK (octet_length(record_hash) = 32)
);
ALTER TABLE bell.audit_events OWNER TO bell_app;
DROP TRIGGER IF EXISTS bell_audit_events_immutable ON bell.audit_events;
CREATE TRIGGER bell_audit_events_immutable
BEFORE UPDATE OR DELETE ON bell.audit_events
FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change();
CREATE INDEX IF NOT EXISTS bell_audit_scope_time_idx
ON bell.audit_events(tenant_id, site_id, occurred_at DESC, event_id DESC);
CREATE INDEX IF NOT EXISTS bell_audit_device_time_idx
ON bell.audit_events(tenant_id, site_id, device_id, occurred_at DESC, event_id DESC);
CREATE TABLE IF NOT EXISTS bell.audit_relay_receipts (
key_id text NOT NULL,
nonce text NOT NULL,
request_hash bytea NOT NULL,
response_status integer NOT NULL,
response_body jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT clock_timestamp(),
expires_at timestamptz NOT NULL,
PRIMARY KEY (key_id, nonce),
CONSTRAINT bell_audit_receipt_key_id CHECK (
char_length(key_id) BETWEEN 1 AND 64 AND key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'
),
CONSTRAINT bell_audit_receipt_nonce CHECK (
char_length(nonce) BETWEEN 22 AND 64 AND nonce ~ '^[A-Za-z0-9_-]+$'
),
CONSTRAINT bell_audit_receipt_hash_length CHECK (octet_length(request_hash) = 32),
CONSTRAINT bell_audit_receipt_status CHECK (response_status = 200),
CONSTRAINT bell_audit_receipt_body CHECK (jsonb_typeof(response_body) = 'object'),
CONSTRAINT bell_audit_receipt_ttl CHECK (
expires_at >= received_at + interval '10 minutes'
AND expires_at <= received_at + interval '11 minutes'
)
);
ALTER TABLE bell.audit_relay_receipts OWNER TO bell_app;
CREATE INDEX IF NOT EXISTS bell_audit_receipt_expiry_idx
ON bell.audit_relay_receipts(expires_at, key_id, nonce);
INSERT INTO bell.schema_migrations(version) VALUES (4)
ON CONFLICT (version) DO NOTHING;
INSERT INTO sense.schema_migrations(version) VALUES (6)
ON CONFLICT (version) DO NOTHING;
@@ -0,0 +1,11 @@
-- Bell runtime may append global audit facts and maintain only short-lived
-- idempotency receipts. Sense keeps ownership of its local Outbox.
REVOKE ALL ON TABLE bell.audit_events, bell.audit_relay_receipts FROM PUBLIC;
REVOKE ALL ON TABLE bell.audit_events, bell.audit_relay_receipts FROM bell_runtime;
GRANT SELECT, INSERT ON TABLE bell.audit_events TO bell_runtime;
GRANT SELECT, INSERT, DELETE ON TABLE bell.audit_relay_receipts TO bell_runtime;
REVOKE ALL ON TABLE bell.audit_events, bell.audit_relay_receipts FROM sense_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE sense.device_operation_outbox TO sense_app;
+12 -5
View File
@@ -1,9 +1,11 @@
# YoVision PostgreSQL 初始化 # YoVision PostgreSQL 初始化
本目录实现 T-009 的 PostgreSQL `17.10` 初始 schema。SQL 按文件名前缀顺序执行:先创建 NOLOGIN 权限角色,再创建 Bell、Sense 对象,最后收窄跨 schema 权限。应用登录角色及其密码由部署环境或密钥系统创建,并分别加入 `bell_app`、`sense_app`;仓库不保存登录凭据。 本目录实现 T-009~T-012、T-015~T-016 的 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` 最小权限;`014`~`015` 增加审计 Outbox relay fencing、Bell 全局审计事实、短期防重收据和双方最小权限。全部 SQL 可重放。对象 owner/迁移角色为 `bell_app`/`sense_app`;应用登录角色及密码由部署环境或密钥系统创建,Sense 登录加入 `sense_app`,Bell 运行登录只加入 `bell_runtime`,仓库不保存登录凭据。
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志: 生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
安装 `008` 前必须先查询并为全部历史 `sense.devices.area_id IS NULL/blank` 记录完成经批准的 Area 归属;migration 会失败关闭而不会猜测或自动搬迁这些设备。
```powershell ```powershell
$env:YOVISION_PG_ADMIN_DSN = '由管理员私下设置' $env:YOVISION_PG_ADMIN_DSN = '由管理员私下设置'
Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql | Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql |
@@ -15,11 +17,16 @@ Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql |
## 权限边界 ## 权限边界
- `bell_app` 拥有 `bell.sites`、版本 trigger 和 `bell.site_quota_v1`。 - `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` 拥有 `sense` schema,只获得 `bell` schema 的 `USAGE` 和两个投影视图的 `SELECT`。
- `sense_app` 对 `bell.sites`、Bell migration 表和 trigger function 没有权限。 - `sense_app` 对 Bell 源表、Bell migration 表和 trigger function 没有权限;启动检查发现权限过宽时拒绝运行。
- `sense.device_operation_outbox` 是本地持久化队列,Bell 全局审计真相只写入 `bell.audit_events`。Sense 只领取/确认本地行,不获得 Bell 表权限;Bell 只通过签名 HTTP ingress 收取,不读取 Outbox。
- `bell.audit_events` 与事件事实一样不可更新/删除;`bell.audit_relay_receipts` 只为 10 分钟 nonce 幂等窗口保留,Bell runtime 仅可在这张限定表中查询、插入和清理过期记录。
- `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 的表和函数没有权限。 - PUBLIC 对两个业务 schema 的表和函数没有权限。
## 恢复 ## 恢复
T-009 不迁移或删除现有 SQLite 数据,默认运行配置也仍为 SQLite。若 PostgreSQL 切换验证失败,停止 PostgreSQL 模式的 Sense 进程并恢复 `SENSE_DB_DRIVER=sqlite` 与原 SQLite DSN。初始 schema 不提供自动 down migration;需要移除时由管理员先备份,再只删除明确确认的 YoVision 专用数据库,禁止对共享实例做模糊或递归清理。 T-009~T-012 不迁移或删除现有 SQLite 数据,默认运行配置也仍为 SQLite。SQLite 不具备生产 Area/Outbox/Control API、多实例租约或孤儿处置一致性,业务路由不会在 SQLite 上注册。若 PostgreSQL 切换验证失败,先关闭 Control API 和孤儿扫描;需要实验室回退时停止 PostgreSQL 模式进程并恢复 SQLite DSN,不得误报为生产语义等价。schema 不提供自动 down migration;需要移除时由管理员先备份,再只删除明确确认的 YoVision 专用数据库,禁止对共享实例做模糊或递归清理。
+166 -1
View File
@@ -6,10 +6,12 @@ DECLARE
default_quota integer; default_quota integer;
initial_version bigint; initial_version bigint;
updated_version bigint; updated_version bigint;
area_initial_version bigint;
area_updated_version bigint;
BEGIN BEGIN
IF current_setting('server_version_num')::integer < 170000 IF current_setting('server_version_num')::integer < 170000
OR current_setting('server_version_num')::integer >= 180000 THEN OR current_setting('server_version_num')::integer >= 180000 THEN
RAISE EXCEPTION 'T-009 requires PostgreSQL 17.x'; RAISE EXCEPTION 'YoVision requires PostgreSQL 17.x';
END IF; END IF;
SELECT array_agg(column_name ORDER BY ordinal_position) SELECT array_agg(column_name ORDER BY ordinal_position)
@@ -37,6 +39,65 @@ BEGIN
RAISE EXCEPTION 'sense_app has forbidden Bell source-table privileges'; RAISE EXCEPTION 'sense_app has forbidden Bell source-table privileges';
END IF; 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) <> 4
OR (SELECT max(version) FROM sense.schema_migrations) <> 6 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) INSERT INTO bell.sites(tenant_id, id, name)
VALUES ('assertion-tenant', 'assertion-site', 'Assertion Site') VALUES ('assertion-tenant', 'assertion-site', 'Assertion Site')
RETURNING max_video_channels, version INTO default_quota, initial_version; RETURNING max_video_channels, version INTO default_quota, initial_version;
@@ -51,6 +112,28 @@ BEGIN
RAISE EXCEPTION 'Bell site source_version is not monotonic'; RAISE EXCEPTION 'Bell site source_version is not monotonic';
END IF; 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 BEGIN
INSERT INTO bell.sites(tenant_id, id, name, max_video_channels) INSERT INTO bell.sites(tenant_id, id, name, max_video_channels)
VALUES ('assertion-tenant', 'invalid-site', 'Invalid', 129); VALUES ('assertion-tenant', 'invalid-site', 'Invalid', 129);
@@ -59,7 +142,89 @@ BEGIN
WHEN check_violation THEN NULL; WHEN check_violation THEN NULL;
END; END;
DELETE FROM bell.areas
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-area';
DELETE FROM bell.sites DELETE FROM bell.sites
WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-site'; WHERE tenant_id = 'assertion-tenant' AND id = 'assertion-site';
END END
$assertions$; $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$;
DO $audit_relay$
BEGIN
IF NOT has_table_privilege('yovision_t012_sense', 'sense.device_operation_outbox', 'SELECT,INSERT,UPDATE,DELETE')
OR has_table_privilege('yovision_t012_sense', 'bell.audit_events', 'SELECT,INSERT,UPDATE,DELETE')
OR has_table_privilege('yovision_t012_sense', 'bell.audit_relay_receipts', 'SELECT,INSERT,UPDATE,DELETE') THEN
RAISE EXCEPTION 'Sense runtime violates audit relay schema ownership';
END IF;
IF NOT has_table_privilege('yovision_t015_bell', 'bell.audit_events', 'SELECT,INSERT')
OR has_table_privilege('yovision_t015_bell', 'bell.audit_events', 'UPDATE,DELETE,TRUNCATE')
OR NOT has_table_privilege('yovision_t015_bell', 'bell.audit_relay_receipts', 'SELECT,INSERT,DELETE')
OR has_table_privilege('yovision_t015_bell', 'bell.audit_relay_receipts', 'UPDATE,TRUNCATE') THEN
RAISE EXCEPTION 'Bell runtime violates audit relay privileges';
END IF;
IF has_table_privilege('public', 'bell.audit_events', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE')
OR has_table_privilege('public', 'bell.audit_relay_receipts', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') THEN
RAISE EXCEPTION 'Bell audit relay tables leaked to PUBLIC';
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='sense' AND table_name='device_operation_outbox'
AND column_name='relay_lease_token'
) THEN
RAISE EXCEPTION 'Sense audit relay fencing columns are missing';
END IF;
END
$audit_relay$;
+8 -3
View File
@@ -40,13 +40,13 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
## 当前阶段 ## 当前阶段
当前为 **M0 指定型号实机准入已完成,M1 Sense 五路混合源集成待执行**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。 当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储及 Sense→Bell 全局审计 relay 基础**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置,不阻塞 Brain/Bell 本地事件链开发。
优先路径: 优先路径:
1. M0:已用一台真实样机完成首期指定“型号 + 硬件版本 + 固件”ONVIF/RTSP 准入;结论不外推为多品牌兼容,真实断网恢复证据按负责人豁免留痕。 1. M0:已用一台真实样机完成首期指定“型号 + 硬件版本 + 固件”ONVIF/RTSP 准入;结论不外推为多品牌兼容,真实断网恢复证据按负责人豁免留痕。
2. M1:只在 `Sense/` 建立 MediaMTX 生产接入骨架,以 1 路准入实机 + 至少 4 路独立合成源完成五路自动建 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 路分片、管理端和第二/第三场景包。
@@ -85,10 +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 go -C Sense generate ./internal/mtx ./internal/controlapi
go -C Sense test ./... go -C Sense test ./...
go -C Sense vet ./... go -C Sense vet ./...
go -C Sense build ./... go -C Sense build ./...
go -C Bell test ./...
go -C Bell vet ./...
go -C Bell build ./...
``` ```
日常优先运行根目录 `./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)。 日常优先运行根目录 `./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)。
+35 -5
View File
@@ -50,6 +50,27 @@ MediaMTX 官方 `v1.19.3` OpenAPI 固定保存于 `Sense/api/vendor/mediamtx-v1.
M2 仍保留 SQLite 作为单机开发与 T-006 回归入口,但生产真相源切换目标是 PostgreSQL。运行时必须显式设置 `SENSE_DB_DRIVER=postgres` 和私有 `SENSE_DB_DSN` 才使用 PostgreSQL;仓库不提供带密码 DSN。初始 schema 由 `deploy/postgres/` 的顺序 migration 创建,不在 Sense 进程启动时使用高权限自动迁移。 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 类型当成公共网络协议。
### 1.4 Sense 审计 relay(T-016)
T-016 不增加第三方依赖:两端使用 Go 标准库 HTTP、HMAC-SHA256、SHA-256、base64url 和 constant-time compare,数据库继续使用已冻结的 PostgreSQL 17.10/pgx。`cmd/bell-api` 只提供回环 health/ready 和 Sense 审计内部端点;非回环监听必须同时提供绝对路径 TLS 证书/私钥。HMAC key 使用仓库外 version 1 JSON 文件,secret 至少 32 字节;该适配器不替代未来 Bell 公共 JWT/OIDC。
## 2. 外部项目边界 ## 2. 外部项目边界
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。 - MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
@@ -61,7 +82,7 @@ M2 仍保留 SQLite 作为单机开发与 T-006 回归入口,但生产真相
- Python、Savant/DeepStream 的精确版本;Go、MediaMTX 与 PostgreSQL 已分别为 Sense M1/M2 冻结,后续阶段可按升级流程调整。 - Python、Savant/DeepStream 的精确版本;Go、MediaMTX 与 PostgreSQL 已分别为 Sense M1/M2 冻结,后续阶段可按升级流程调整。
- Bell 前端框架和组件库。 - Bell 前端框架和组件库。
- 事件投递 transport 从 HTTP 起步还是直接采用消息总线。 - Brain→Bell 业务事件投递 transport;Sense→Bell 审计 relay 已独立冻结为内部 HTTP,不能据此默认 Brain transport。
- 目标 GPU/边缘硬件、解码能力和每 worker 的 `max_sources`。 - 目标 GPU/边缘硬件、解码能力和每 worker 的 `max_sources`。
- MinIO/S3 的精确版本、加密实现,以及客户/法务确认后的最终生命周期策略。 - MinIO/S3 的精确版本、加密实现,以及客户/法务确认后的最终生命周期策略。
- 短信/语音供应商及生产双路径组合;是否开发原生 App 最早在 M4 根据试点反馈决定。 - 短信/语音供应商及生产双路径组合;是否开发原生 App 最早在 M4 根据试点反馈决定。
@@ -86,13 +107,22 @@ Sense 单独执行:
```powershell ```powershell
go -C Sense mod download go -C Sense mod download
go -C Sense generate ./internal/mtx go -C Sense generate ./internal/mtx ./internal/controlapi
go -C Sense test ./... go -C Sense test ./...
go -C Sense vet ./... go -C Sense vet ./...
go -C Sense build ./... go -C Sense build ./...
go -C Sense run ./cmd/sense-api go -C Sense run ./cmd/sense-api
``` ```
Bell 事件域与内部审计 receiver 单独执行:
```powershell
go -C Bell mod download
go -C Bell test ./...
go -C Bell vet ./...
go -C Bell build ./...
```
直接验证: 直接验证:
```powershell ```powershell
@@ -107,10 +137,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 -C Sense generate ./internal/mtx`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 | | 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 前后不变 | | 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`。
+19 -15
View File
@@ -45,10 +45,10 @@ Sense ── 视频流/触发信号 ──> Brain
主流程: 主流程:
1. Bell 持有站点、Area、配额与 `capture_policy`;首期在同一 PostgreSQL 实例内发布版本化只读视图 `bell.site_quota_v1`。T-009 已实现 Bell 源表/视图、最小权限和 Sense PostgreSQL repository;Sense 在新增/启用设备的同一事务内以站点 advisory lock 校验配额并记录所用 `source_version`。未来分库必须发布新版本契约,不能静默改变 v1 语义。 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 的数据闭环。
@@ -63,12 +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` 并拥有 `bell.site_quota_v1`;Sense 角色只获得视图 `SELECT`,在设备写路径执行准入,不能写 Bell schema 或读取 Bell 源表。 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`,不得用模拟遥测伪装交付。 10. 设备领域模型使用 `modality + capabilities`,页面不以摄像头作为唯一根实体;未实现协议适配器明确为 `adapter_not_ready`,不得用模拟遥测伪装交付。
11. Tenant/Site/Area/RBAC、配额、`capture_policy` 与全局审计属于 Bell;Sense Control API v1 只管理 Device 期望态与收敛查询,Sense 只读消费版本化投影并在设备写路径执行,投影不可用时只阻断相关新变更,不静默切断已有链路。 11. Tenant/Site/Area/RBAC、配额、`capture_policy` 与全局审计属于 Bell;Sense Control API v1 只管理 Device 期望态与收敛查询,Sense 只读消费版本化投影并在设备写路径执行,投影不可用时只阻断相关新变更,不静默切断已有链路。
12. Sense 的设备操作审计先写本地持久化 outbox,再由幂等 relay 异步送入 Bell 全局审计;不得使用“先执行高风险操作、再尽力入队”的顺序。具体字段、签名、重放与留存契约必须由独立 API/契约任务冻结后实现。 12. Sense 的设备操作审计先写本地持久化 Outbox,再由幂等 relay 异步送入 Bell 全局审计;不得使用“先执行高风险操作、再尽力入队”的顺序。T-016 已实现 HMAC/nonce 内部 HTTP relay、数据库时钟 lease/fencing、逐项确认与 Bell 不可变全局事实;Sense 不获得 Bell schema 权限,Bell 不读取 Sense Outbox。
## 6. 容量架构 ## 6. 容量架构
@@ -79,14 +79,18 @@ 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 `sense` schema 是生产期望态真相源;SQLite 只保留为 M1 本地开发/回归路径。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.site_quota_v1` 行缺失、数值越界、版本回退或读取失败只阻止视频设备新增/启用,不中断已有流;降低配额导致超限时不自动停用,后续准入返回稳定错误并产生运维信号。多 Sense 实例使用 PostgreSQL transaction-scoped advisory lock 串行化同 tenant/site 的计数与写入,不能用进程内锁替代。
- Area 策略投影读取失败只阻止相关设备新增/启用;已有设备保持原状态并产生运维告警。策略变更与已有成像设备冲突时由 Bell 管理端显式处置。 - `bell.area_policy_v1` 缺失、非法、版本回退或读取失败时,PostgreSQL repository 拒绝相关新增/启用;`non_imaging_only` 允许非成像设备但拒绝具有 `video_capture` 的设备。已有设备保持原状态,策略冲突由 Bell 管理端显式迁移或取消。同库实时视图不以源记录年龄误判 freshness。
- 高风险设备操作在本地事务内同时写期望态与审计 outbox;异步 relay 可重试、幂等投递到 Bell。T-004 只验证交互归属,不定义或实现接口契约。 - 设备创建和期望态受理在本地事务内同时写脱敏 `sense.device_operation_outbox`;Outbox 失败回滚业务写入,相同期望态不增加 generation 但仍审计。relay 最多领取 100 行,以 30 秒数据库 lease 和单调 fencing token 防止过期 worker 确认;成功和 dead letter 都保留本地事实。
- Bell 先验证时间窗、nonce 和 constant-time HMAC,再逐项校验 v1/v2 事件;同 nonce/同摘要重放原结果,同 nonce/不同摘要拒绝。`bell.audit_events` 只追加且不自动清理,只有 10 分钟幂等收据允许 Bell runtime 删除过期行。
- Bell 最终事件写入 `bell.events`;同平台 ID/同摘要仅视为幂等重放,同 ID/不同摘要拒绝。`bell_runtime` 只有 `SELECT/INSERT`,事件与 outcome 的 UPDATE/DELETE 另由数据库 trigger 拒绝;后续人工/自动 outcome 追加到独立表,不改写事件 payload。
- Brain 投递失败落本地队列重试,不阻塞实时推理主链路。 - Brain 投递失败落本地队列重试,不阻塞实时推理主链路。
- Alert 先落库再投递,进程重启恢复未完成升级链。 - Alert 先落库再投递,进程重启恢复未完成升级链。
- 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。 - 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。
@@ -94,8 +98,8 @@ Sense ── 视频流/触发信号 ──> Brain
## 8. 数据与契约 ## 8. 数据与契约
- Bell 核心实体:Tenant → Site → Area(含 `capture_policy`)以及 Role/Binding/Quota/Audit;Sense 核心实体:Device(含 `modality + capabilities`)→ StreamBinding/Zone,以及带 `source_version`/`synced_at` 的 SiteQuota/AreaPolicyProjection。两个 schema 以稳定逻辑 ID 关联,不跨 schema 写入;配额 v1 的跨 schema 列签名限定为 `tenant_id/site_id/max_video_channels/source_version/source_updated_at`。 - 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` 六列。
- Sense Control API v1 使用站点作用域路径、认证上下文 tenant、cursor 分页、幂等键与 ETag;敏感连接引用只写不读。正式签名和兼容规则以 [`contracts/`](contracts/) 为准。T-009 只实现 PostgreSQL migration/repository,不等于 handler、认证或 Bell 管理服务已实现。 - Sense Control API v1 使用站点作用域路径、认证上下文 tenant、HMAC cursor 分页、PostgreSQL 幂等收据与资源 ETag;敏感连接引用只写不读。T-011 已实现 7 个 handler,并以 feature flag 限定到 PostgreSQL 路径;首版外部静态 SHA-256 注册表只实现认证 port 的私有部署适配器。T-016 审计 relay 采用独立外部 HMAC key 文件和内部端点,不等同于 Bell 公共管理认证。正式签名和兼容规则以 [`contracts/`](contracts/) 为准。
- 业务实体:Rule → Event → Alert → DeliveryAttempt/Ack;Event 与 Alert 不合并。 - 业务实体:Rule → Event → Alert → DeliveryAttempt/Ack;Event 与 Alert 不合并。
- Bell 通知域分为三个聚合:Contact/Team 保存身份、成员关系和已验证通道;OnCallSchedule/ScheduleVersion/ShiftException 保存时区、轮换与例外;EscalationPolicy/Step 通过 `person / team / on_call_schedule` 类型化 `target_ref` 引用目标。三者共享逻辑 ID,不复制手机号、班次或轮换字段。 - Bell 通知域分为三个聚合:Contact/Team 保存身份、成员关系和已验证通道;OnCallSchedule/ScheduleVersion/ShiftException 保存时区、轮换与例外;EscalationPolicy/Step 通过 `person / team / on_call_schedule` 类型化 `target_ref` 引用目标。三者共享逻辑 ID,不复制手机号、班次或轮换字段。
- 每个 DeliveryAttempt 创建时解析当时生效的排班版本,并保存实际收件人、通道、`schedule_version` 和解析时间快照;之后联系人或排班修改不得回写既有投递事实。 - 每个 DeliveryAttempt 创建时解析当时生效的排班版本,并保存实际收件人、通道、`schedule_version` 和解析时间快照;之后联系人或排班修改不得回写既有投递事实。
@@ -107,20 +111,20 @@ 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,002_bell.sql,003_sense.sql,004_privileges.sql,tests} deploy/postgres/{001_roles.sql,...,015_privileges_audit_relay.sql,tests}
``` ```
当前只有空目录占位;真实脚手架必须由对应任务创建。 Sense 脚手架和 PostgreSQL `001`~`015` 已实现;Bell 已有事件校验/不可变存储 Go 基础和只面向 Sense 审计 relay 的最小 `bell-api`,但没有公共管理 API,Brain 仍为目录占位。
## 10. 开发顺序 ## 10. 开发顺序
- M0 不写生产代码。 - M0 不写生产代码。
- M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。 - 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 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。 - M4/M5 再做 64/128 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。
+7 -4
View File
@@ -24,10 +24,11 @@
- T-008:冻结 Sense Control API v1 与 Bell `site_quota_v1` 只读投影契约;只建立接口和测试门禁,不等于 handler、Bell 表或 migration 已实现。 - T-008:冻结 Sense Control API v1 与 Bell `site_quota_v1` 只读投影契约;只建立接口和测试门禁,不等于 handler、Bell 表或 migration 已实现。
- T-009:使用 PostgreSQL 17.10 建立 `sense`/`bell` schema、Bell 配额源表/只读视图、最小权限和 Sense PostgreSQL repository;SQLite 保留为 M1 本地路径。 - T-009:使用 PostgreSQL 17.10 建立 `sense`/`bell` schema、Bell 配额源表/只读视图、最小权限和 Sense PostgreSQL repository;SQLite 保留为 M1 本地路径。
- 后续任务按 T-008 契约实现认证 tenant 上下文、设备管理 handler、幂等/ETag/cursor 与批量操作;不得把 T-009 数据基础误报为 API 已交付。 - T-010:建立 Bell Area/`capture_policy` 只读投影、Sense 成像准入与设备操作审计 Outbox 原子写入;relay 传输协议留给后续任务。
- 对账器幂等/退避/并发/10% 安全闸。 - T-011:按 T-008 契约实现认证 tenant 上下文、7 个设备/operation handler、PostgreSQL 幂等收据、ETag/HMAC cursor、最多 128 项批量操作和停用精确 path 收敛;公共控制 API 默认关闭且只在 PostgreSQL 路径启用。
- WireGuard 边缘隧道与断网恢复。 - T-012:以 PostgreSQL 数据库时钟租约和 fencing token 防止多实例重复调和;增加可证明的 Path 历史归属、默认只读孤儿报告、15 分钟二次确认、10% 不可绕过安全闸和低基数 `/metrics`。
- 16 路批量开通、停用和容量基准。 - 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
@@ -35,6 +36,8 @@
- 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。
- T-016:冻结并实现 Sense Outbox → Bell 内部审计 relay;使用 HMAC、nonce 收据、数据库时钟 lease/fencing、逐项确认和 dead letter,在不共享 schema 权限的前提下写入 Bell 不可变全局审计事实。
- 规则引擎、场景包加载、预警状态机与双路径投递。 - 规则引擎、场景包加载、预警状态机与双路径投递。
- 最小 Web/App 处置流程、RBAC 与审计。 - 最小 Web/App 处置流程、RBAC 与审计。
- 现场误报基线和反馈队列。 - 现场误报基线和反馈队列。
+15 -9
View File
@@ -1,6 +1,6 @@
# API 与契约 # API 与契约
> Brain → Bell 事件契约 v0.1、Sense Control API v1 与 Bell 站点配额只读投影 v1 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。 > Brain → Bell 事件契约 v0.1、Sense Control API v1、Bell 配额/Area 只读投影 v1、Sense 本地设备审计事件 v1/v2 与 Sense→Bell 审计 relay v1 已冻结;其他 API 仍在设计阶段。不得把本文的“待定”自行具体化为公共契约。
## 1. 已冻结:Brain → Bell 事件契约 ## 1. 已冻结:Brain → Bell 事件契约
@@ -17,17 +17,21 @@
- 证据文件名只含事件 ID 与日期目录,不含 IP、端口、凭据或客户名。 - 证据文件名只含事件 ID 与日期目录,不含 IP、端口、凭据或客户名。
- `sensors` 中恰有一个 primary,且其 `device_id` 与顶层一致。 - `sensors` 中恰有一个 primary,且其 `device_id` 与顶层一致。
T-015 已实现 Bell 消费端的内部组装与存储边界:可信 ingress 先接收“不含平台 `id`”的候选事实,Bell 生成 `evt_` ULID 后形成最终 v0.1 对象,再执行 schema 与六项代码断言并不可变落库。该候选类型是 Bell 内部 port,不是 Brain 可依赖的 HTTP/消息总线协议;transport、认证和重放语义仍由后续任务冻结。
## 2. 跨系统接口状态 ## 2. 跨系统接口状态
| 调用方 → 提供方 | 用途 | 当前约束 | 状态 | | 调用方 → 提供方 | 用途 | 当前约束 | 状态 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Sense → Bell | 读取站点视频配额 | 同一 PostgreSQL 实例内只读 `bell.site_quota_v1`;默认 16、最大 128;失败时拒绝新增/启用但不影响已有流 | T-008 冻结,T-009 已实现数据路径 | | 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 | 汇入设备操作审计 | `POST /internal/v1/audit-events:batch`;1~100 项、1 MiB、10 秒 deadline、HMAC/nonce、逐项确认;非回环必须 HTTPS | T-016 已冻结并实现 |
| 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 设计 |
冻结签名和失败语义见 [`contracts/README.md`](contracts/README.md) 与 [`contracts/site-quota-v1.sql`](contracts/site-quota-v1.sql)。Bell 拥有源数据和视图,Sense 数据库角色只有 `SELECT`;未来分库必须发布新版本,不能在 v1 下把本地视图静默替换为网络调用。 冻结签名和失败语义见 [`contracts/README.md`](contracts/README.md)、[`contracts/sense-audit-relay-v1.openapi.json`](contracts/sense-audit-relay-v1.openapi.json)、[`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 ## 3. 已冻结:Sense Control API v1
@@ -43,7 +47,7 @@
- `endpoint_ref`、`credential_ref`、`profile_token` 只写不读;设备 ID 由服务端生成。普通响应和错误不得包含凭据、完整流 URI、token 或 MediaMTX 内部配置。 - `endpoint_ref`、`credential_ref`、`profile_token` 只写不读;设备 ID 由服务端生成。普通响应和错误不得包含凭据、完整流 URI、token 或 MediaMTX 内部配置。
- v1 不提供删除设备;停用设备保留历史。写入受理只表示期望态已持久化,不能表示实际态已收敛。 - v1 不提供删除设备;停用设备保留历史。写入受理只表示期望态已持久化,不能表示实际态已收敛。
T-008 冻结契约;T-009 已实现 PostgreSQL `bell.sites`/配额视图、最小权限和 Sense repository,但尚未实现 Sense HTTP handler、认证中间件或 Bell 管理服务。 T-008 冻结公共控制契约;T-009/T-010 建立 PostgreSQL 投影、准入和本地审计基础;T-011 已实现 7 个 HTTP handler、外部静态摘要认证适配器、tenant/Site scope、幂等收据、ETag/HMAC cursor 和持久化 batch operation。业务路由默认关闭且仅可在 PostgreSQL 上开启;T-016 的审计 relay 是独立内部端点,不替代 Bell 管理服务或 JWT/OIDC。
## 4. 待冻结的 Bell 公共 API ## 4. 待冻结的 Bell 公共 API
@@ -67,21 +71,23 @@ Sense 使用 MediaMTX 官方 OpenAPI 生成客户端并加薄封装。业务代
| Port | 操作 | 数据所有者 / 失败语义 | | Port | 操作 | 数据所有者 / 失败语义 |
| --- | --- | --- | | --- | --- | --- |
| ONVIF adapter | `Probe(target)`、`SetSystemDateAndTime(target, time)` | 设备是外部来源;`target` 只含 endpoint ref 与不透明 credential ref。错误稳定映射为认证失败、超时、不可用、响应无效,不记录凭据或完整流地址 | | ONVIF adapter | `Probe(target)`、`SetSystemDateAndTime(target, time)` | 设备是外部来源;`target` 只含 endpoint ref 与不透明 credential ref。错误稳定映射为认证失败、超时、不可用、响应无效,不记录凭据或完整流地址 |
| MediaMTX paths | `CreatePath`、`GetPath`、`EnsurePath`、`DeletePath`、`PathReady` | SQLite 设备台账持有期望态,MediaMTX 只持有运行配置;`EnsurePath` 相同 source 不写、不同 source patch、缺失时 add;当前调和器绝不枚举或删除孤儿 | | MediaMTX paths | `CreatePath`、`GetPath`、`EnsurePath`、`DeletePath`、`PathReady`、`ListPathNames` | 设备台账持有期望态,MediaMTX 只持有运行配置;分页枚举只返回名称、不返回 source。普通调和只操作精确台账 Path;独立孤儿流程只报告未知归属,受控处置只接受有历史归属的 stale Path |
| Device repository | 设备、期望态、实际态、调和进度 | SQLite 是 M1 默认开发路径;PostgreSQL 是 M2 生产路径并只读 `bell.site_quota_v1`。调和失败次数与下次时间持久化,进程重启不清空退避;配额读取失败时拒绝新增或启用,不关闭已有流 | | Device repository | 设备、期望态、实际态、调和进度 | SQLite 是 M1 默认开发路径;PostgreSQL 是 M2 生产路径并只读 Site/Area 两个 Bell 视图。调和退避持久化;配额/Area 失败时拒绝相关新增或启用,不关闭已有流;高风险写入与脱敏 Outbox 同事务 |
MediaMTX 薄封装调用同版官方 OpenAPI 的 `/v3/config/paths/get|add|patch|delete/{name}` 与 `/v3/paths/get/{name}`。生成源、版本和 SHA-256 见 `docs/03-tech-stack.md`;业务包不得直接 import 生成包。 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 设备台账语义 ### 5.2 设备台账语义
- 设备类型由 `modality` 表达物理类别,由多值 `capabilities` 表达视频采集、音频、空间规则或遥测能力,避免把“摄像头”固化为唯一设备模型。 - 设备类型由 `modality` 表达物理类别,由多值 `capabilities` 表达视频采集、音频、空间规则或遥测能力,避免把“摄像头”固化为唯一设备模型。
- 视频配额只统计 `desired_state=enabled` 且具有 `video_capture` capability 的设备;站点默认 16、可配置 1~128。禁用设备和非视频传感器不占视频路数。 - 视频配额只统计 `desired_state=enabled` 且具有 `video_capture` capability 的设备;站点默认 16、可配置 1~128。禁用设备和非视频传感器不占视频路数。
- SQLite 表使用 `sense_` 前缀;T-009 PostgreSQL 使用 `sense.devices`、`sense.device_capabilities`、`sense.reconcile_state` 和只记录已观察版本的 `sense.site_quota_projection_state`。PostgreSQL 不建立可写 Site 真相副本,站点与配额只来自 Bell 视图;SQLite 的 `sense_sites` 仅是 M1 本地兼容表,不是跨系统公共契约。 - SQLite 表使用 `sense_` 前缀且只是 M1 实验室兼容路径;PostgreSQL 使用 `sense.devices`、能力/调和表、两个投影观察表和 `sense.device_operation_outbox`。PostgreSQL 不建立可写 Site/Area 真相副本;后续公共控制 API 只在 PostgreSQL 路径启用,不能把 SQLite 描述为 Area/Outbox 生产等价实现。
- 摄像头密码不进入设备普通字段。`credential_ref` 只保存外部密钥引用;ONVIF 返回的 stream URI 只在内存中传给 MediaMTX,不写入设备台账或日志。 - 摄像头密码不进入设备普通字段。`credential_ref` 只保存外部密钥引用;ONVIF 返回的 stream URI 只在内存中传给 MediaMTX,不写入设备台账或日志。
### 5.3 Sense 进程 HTTP 面 ### 5.3 Sense 进程 HTTP 面
T-003 只实现了运维探针:`GET /healthz` 表示进程存活,`GET /readyz` 表示所选数据库已打开且 schema/权限前置检查完成。两者返回 JSON,均不等价于摄像头、MediaMTX path 或里程碑健康。T-008 已冻结站点作用域的 `/api/v1/sites/{site_id}/devices` 等设备管理契约,但尚未实现对应 handler;当前可运行进程仍只暴露探针,后续实现不得发布 `/api/v1/devices` 等无站点边界的临时接口。 `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. 变更流程 ## 6. 变更流程
@@ -90,4 +96,4 @@ T-003 只实现了运维探针:`GET /healthz` 表示进程存活,`GET /ready
3. 同步生产者、消费者、契约测试和示例。 3. 同步生产者、消费者、契约测试和示例。
4. 记录迁移、回滚与版本废弃策略。 4. 记录迁移、回滚与版本废弃策略。
事件 v0.1、Sense Control API v1 或站点配额投影 v1 的破坏性变化必须发布新版本,不能原地修改已被生产者/消费者使用的契约。 事件 v0.1、Sense Control API v1、配额/Area 投影 v1 或本地设备审计 v1 的破坏性变化必须发布新版本,不能原地修改已被生产者/消费者使用的契约。
+30 -4
View File
@@ -1,6 +1,6 @@
# Sense 控制面与站点配额契约 v1 # Sense 控制面、准入投影与本地审计契约 v1
> 冻结日期:2026-08-07。契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC 与配额的所有者。本文冻结接口,不表示 HTTP handler、Bell 表或 PostgreSQL migration 已实现。 > 冻结日期:2026-08-11。Control API 与审计 relay 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额与全局审计的所有者。T-016 已实现 Sense Outbox 到 Bell 的内部 relay;Bell 公共管理服务与 JWT/OIDC 仍未实现。
## 契约文件 ## 契约文件
@@ -8,6 +8,10 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| [`sense-control-v1.openapi.json`](sense-control-v1.openapi.json) | Sense | Bell 管理面、受控集成方 | 设备查询、创建、修改、启停与批量操作 | | [`sense-control-v1.openapi.json`](sense-control-v1.openapi.json) | Sense | Bell 管理面、受控集成方 | 设备查询、创建、修改、启停与批量操作 |
| [`site-quota-v1.sql`](site-quota-v1.sql) | Bell | Sense | 单 PostgreSQL 实例内的站点视频配额只读投影 | | [`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 文件保持不变 |
| [`sense-audit-relay-v1.openapi.json`](sense-audit-relay-v1.openapi.json) | Bell | Sense | 内部批量端点、HMAC、逐项确认、nonce 防重与重试边界 |
OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。 OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。
@@ -48,7 +52,25 @@ Bell migration 最终创建 `bell.site_quota_v1`,列顺序和含义固定如
配额只统计 `desired_state=enabled` 且 capabilities 含 `video_capture` 的设备。创建已启用视频设备或把视频设备启用时,Sense 必须在同一设备写路径读取并记录所用 `source_version`,同时验证 Area 策略投影。降低配额不会自动停用已有设备;若当前占用已超限,后续创建/启用返回 `409 quota_exceeded`。配额行缺失、越界、版本回退或投影不可读时返回 `503 quota_projection_unavailable`,只阻止相关创建/启用,读取、非准入属性修改和停用仍允许,已有流保持运行。 配额只统计 `desired_state=enabled` 且 capabilities 含 `video_capture` 的设备。创建已启用视频设备或把视频设备启用时,Sense 必须在同一设备写路径读取并记录所用 `source_version`,同时验证 Area 策略投影。降低配额不会自动停用已有设备;若当前占用已超限,后续创建/启用返回 `409 quota_exceeded`。配额行缺失、越界、版本回退或投影不可读时返回 `503 quota_projection_unavailable`,只阻止相关创建/启用,读取、非准入属性修改和停用仍允许,已有流保持运行。
Area/capture policy 的投影形态不在 T-008 中冻结;Sense v1 仍保留 `area_policy_unavailable` 与 `area_policy_denied` 稳定错误语义,后续契约不得放宽同写路径校验要求。 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 继续只定义事实;传输由 `sense-audit-relay-v1.openapi.json` 独立冻结。
## Sense → Bell 审计 relay
Sense 向 `/internal/v1/audit-events:batch` 每批发送 1~100 个事件,请求体不超过 1 MiB、deadline 10 秒。请求用外部文件中的至少 32 字节 secret 做 HMAC-SHA256,canonical string 为 method、path、Unix 秒、随机 nonce 与 body SHA-256 的换行拼接;非回环地址必须使用 HTTPS。Bell 允许 300 秒时钟偏差并将 `(key_id, nonce)` 收据保留 600 秒:相同请求摘要返回原结果,不同摘要返回 `409 replay_conflict`。
Outbox 用 30 秒数据库时钟 lease、单调 fencing token 和 `FOR UPDATE SKIP LOCKED` 协调实例。`accepted/duplicate` 才标记 delivered;逐项 `rejected` 进入 dead letter;网络、5xx、认证失败或缺失结果以 1 秒起步、最多 300 秒指数退避。Sense 不直接访问 Bell schema,Bell 不读取 Sense Outbox;全局 `bell.audit_events` 不自动删除,只有短期 relay receipt 自动过期。
两端读取同格式的仓库外 key 文件;Bell 可同时接受多个 key,Sense 用 `SENSE_AUDIT_RELAY_KEY_ID` 选择一个,便于先加新 key、切换发送端、再移除旧 key。占位结构如下,`secret_base64url` 必须替换为至少 32 个随机字节的无填充 base64url,不能提交真实值:
```json
{"version":1,"keys":[{"key_id":"sense-a","secret_base64url":"<external-secret>"}]}
```
## 兼容与废弃 ## 兼容与废弃
@@ -63,7 +85,11 @@ Area/capture policy 的投影形态不在 T-008 中冻结;Sense v1 仍保留 `
```powershell ```powershell
python -m json.tool docs/contracts/sense-control-v1.openapi.json | Out-Null 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 json.tool docs/contracts/sense-audit-relay-v1.openapi.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_contract.py"
python -m unittest discover -s tests -p "test_sense_control_implementation.py"
python -m unittest discover -s tests -p "test_sense_audit_relay_contract.py"
``` ```
测试校验本仓库依赖的 OpenAPI 结构与安全不变量,并不替代后续实现任务对完整 OpenAPI 标准验证器、HTTP handler 和 PostgreSQL migration 的验证。 测试同时校验 OpenAPI 结构、生成 server glue、HTTP handler 与 PostgreSQL migration/事务;它不替代 Bell 消费方联合验收或客户现场容量验证。
+30
View File
@@ -0,0 +1,30 @@
-- Contract-only reference for the Bell -> Sense Area admission projection.
-- The executable migration is deploy/postgres/005_area_policy.sql.
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;
COMMENT ON VIEW bell.area_policy_v1 IS
'v1 read-only Area capture-policy projection owned by Bell and consumed by Sense';
ALTER VIEW bell.area_policy_v1 OWNER TO bell_app;
REVOKE ALL PRIVILEGES ON TABLE bell.area_policy_v1 FROM PUBLIC;
REVOKE ALL PRIVILEGES ON TABLE bell.area_policy_v1 FROM sense_app;
GRANT SELECT ON TABLE bell.area_policy_v1 TO sense_app;
@@ -0,0 +1,94 @@
{
"openapi": "3.1.0",
"info": {
"title": "YoVision Sense Audit Relay",
"version": "1.0.0",
"description": "Internal, signed and idempotent delivery of redacted Sense device audit facts to Bell."
},
"paths": {
"/internal/v1/audit-events:batch": {
"post": {
"operationId": "receiveSenseAuditBatch",
"description": "Accepts 1-100 events in a body no larger than 1048576 bytes. The request deadline is 10 seconds. HMAC clock skew is at most 300 seconds and nonce receipts live for 600 seconds.",
"parameters": [
{"name": "X-YoVision-Key-Id", "in": "header", "required": true, "schema": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"}},
{"name": "X-YoVision-Timestamp", "in": "header", "required": true, "description": "Unix seconds", "schema": {"type": "string", "pattern": "^[0-9]{10,}$"}},
{"name": "X-YoVision-Nonce", "in": "header", "required": true, "description": "16-48 random bytes encoded as unpadded base64url", "schema": {"type": "string", "minLength": 22, "maxLength": 64, "pattern": "^[A-Za-z0-9_-]+$"}},
{"name": "X-YoVision-Signature", "in": "header", "required": true, "description": "Unpadded base64url HMAC-SHA256 over POST, path, timestamp, nonce and lowercase SHA-256 body digest joined by newlines", "schema": {"type": "string", "minLength": 43, "maxLength": 43}}
],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BatchRequest"}}}
},
"responses": {
"200": {"description": "Stored, duplicate or permanently rejected per item. Same key ID, nonce and request digest returns the original response.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BatchResponse"}}}},
"400": {"description": "Malformed batch envelope"},
"401": {"description": "Invalid key, timestamp, nonce or signature"},
"409": {"description": "Same key ID and nonce used with a different request digest; error is replay_conflict"},
"413": {"description": "Body exceeds 1048576 bytes"},
"503": {"description": "Bell cannot atomically persist the batch and receipt"}
}
}
}
},
"components": {
"schemas": {
"BatchRequest": {
"type": "object", "additionalProperties": false, "required": ["events"],
"properties": {"events": {"type": "array", "minItems": 1, "maxItems": 100, "items": {"$ref": "#/components/schemas/Envelope"}}}
},
"Envelope": {
"type": "object", "additionalProperties": false, "required": ["schema_version", "event"],
"properties": {
"schema_version": {"type": "integer", "enum": [1, 2]},
"event": {"$ref": "#/components/schemas/AuditEvent"}
}
},
"AuditEvent": {
"type": "object", "additionalProperties": false,
"required": ["event_id", "event_type", "tenant_id", "site_id", "device_id", "actor", "reason", "trace_id", "aggregate_generation", "projection_versions", "data", "occurred_at"],
"properties": {
"event_id": {"type": "string", "pattern": "^audit_[0-9a-f]{32}$"},
"event_type": {"type": "string", "enum": ["device.created", "device.desired_state.accepted", "device.configuration.accepted"]},
"tenant_id": {"$ref": "#/components/schemas/LogicalId"},
"site_id": {"$ref": "#/components/schemas/LogicalId"},
"device_id": {"$ref": "#/components/schemas/LogicalId"},
"actor": {"type": "object", "additionalProperties": false, "required": ["type", "id"], "properties": {"type": {"type": "string", "enum": ["user", "service", "system"]}, "id": {"type": "string", "minLength": 1, "maxLength": 200}}},
"reason": {"type": ["string", "null"], "maxLength": 500},
"trace_id": {"type": ["string", "null"], "maxLength": 128},
"aggregate_generation": {"type": "integer", "minimum": 1},
"projection_versions": {"type": "object", "additionalProperties": false, "required": ["quota_source_version", "area_policy_source_version"], "properties": {"quota_source_version": {"type": ["integer", "null"], "minimum": 1}, "area_policy_source_version": {"type": ["integer", "null"], "minimum": 1}}},
"data": {"type": "object"},
"occurred_at": {"type": "string", "format": "date-time"}
}
},
"LogicalId": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},
"BatchResponse": {
"type": "object", "additionalProperties": false, "required": ["results"],
"properties": {"results": {"type": "array", "minItems": 1, "maxItems": 100, "items": {"$ref": "#/components/schemas/Result"}}}
},
"Result": {
"type": "object", "additionalProperties": false, "required": ["event_id", "status"],
"properties": {
"event_id": {"type": "string"},
"status": {"type": "string", "enum": ["accepted", "duplicate", "rejected"]},
"error_code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}
}
}
}
},
"x-yovision-signature": {
"canonical": "METHOD\\nPATH\\nTIMESTAMP\\nNONCE\\nLOWERCASE_SHA256_BODY",
"algorithm": "HMAC-SHA256",
"encoding": "base64url-no-padding",
"clock_skew_seconds": 300,
"receipt_ttl_seconds": 600
},
"x-yovision-delivery": {
"lease_seconds": 30,
"initial_retry_seconds": 1,
"maximum_retry_seconds": 300,
"successful_statuses": ["accepted", "duplicate"],
"permanent_status": "rejected"
}
}
@@ -0,0 +1,109 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/sense-device-audit-v1.schema.json",
"title": "Sense Device Audit Event v1",
"description": "Sense 本地持久化的设备操作审计事实;不定义 Bell relay 的传输、签名、确认或留存。",
"type": "object",
"additionalProperties": false,
"required": [
"event_id",
"event_type",
"tenant_id",
"site_id",
"device_id",
"actor",
"reason",
"trace_id",
"aggregate_generation",
"projection_versions",
"data",
"occurred_at"
],
"properties": {
"event_id": {
"type": "string",
"pattern": "^audit_[0-9a-f]{32}$"
},
"event_type": {
"enum": ["device.created", "device.desired_state.accepted"]
},
"tenant_id": {"$ref": "#/$defs/logicalId"},
"site_id": {"$ref": "#/$defs/logicalId"},
"device_id": {"$ref": "#/$defs/logicalId"},
"actor": {
"type": "object",
"additionalProperties": false,
"required": ["type", "id"],
"properties": {
"type": {"enum": ["user", "service", "system"]},
"id": {"type": "string", "minLength": 1, "maxLength": 200}
}
},
"reason": {"type": ["string", "null"], "maxLength": 500},
"trace_id": {"type": ["string", "null"], "maxLength": 128},
"aggregate_generation": {"type": "integer", "minimum": 1},
"projection_versions": {
"type": "object",
"additionalProperties": false,
"required": ["quota_source_version", "area_policy_source_version"],
"properties": {
"quota_source_version": {"type": ["integer", "null"], "minimum": 1},
"area_policy_source_version": {"type": ["integer", "null"], "minimum": 1}
}
},
"data": {
"oneOf": [
{"$ref": "#/$defs/deviceCreatedData"},
{"$ref": "#/$defs/desiredStateData"}
]
},
"occurred_at": {"type": "string", "format": "date-time"}
},
"allOf": [
{
"if": {"properties": {"event_type": {"const": "device.created"}}},
"then": {"properties": {"data": {"$ref": "#/$defs/deviceCreatedData"}}}
},
{
"if": {"properties": {"event_type": {"const": "device.desired_state.accepted"}}},
"then": {"properties": {"data": {"$ref": "#/$defs/desiredStateData"}}}
}
],
"$defs": {
"logicalId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"desiredState": {"enum": ["disabled", "enabled"]},
"deviceCreatedData": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "area_id", "modality", "capabilities", "desired_state"],
"properties": {
"kind": {"const": "device_created"},
"area_id": {"$ref": "#/$defs/logicalId"},
"modality": {"enum": ["video", "radar", "contact", "button", "wearable", "other"]},
"capabilities": {
"type": "array",
"maxItems": 16,
"uniqueItems": true,
"items": {"enum": ["video_capture", "audio_capture", "spatial_rule", "telemetry"]}
},
"desired_state": {"$ref": "#/$defs/desiredState"}
}
},
"desiredStateData": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "previous_desired_state", "desired_state", "changed"],
"properties": {
"kind": {"const": "desired_state_accepted"},
"previous_desired_state": {"$ref": "#/$defs/desiredState"},
"desired_state": {"$ref": "#/$defs/desiredState"},
"changed": {"type": "boolean"}
}
}
}
}
@@ -0,0 +1,124 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/sense-device-audit-v2.schema.json",
"title": "Sense Device Audit Event v2",
"description": "v1 的向后兼容后继:增加脱敏设备配置受理事实,仍不定义 Bell relay。",
"type": "object",
"additionalProperties": false,
"required": [
"event_id", "event_type", "tenant_id", "site_id", "device_id",
"actor", "reason", "trace_id", "aggregate_generation",
"projection_versions", "data", "occurred_at"
],
"properties": {
"event_id": {"type": "string", "pattern": "^audit_[0-9a-f]{32}$"},
"event_type": {
"enum": [
"device.created",
"device.desired_state.accepted",
"device.configuration.accepted"
]
},
"tenant_id": {"$ref": "#/$defs/logicalId"},
"site_id": {"$ref": "#/$defs/logicalId"},
"device_id": {"$ref": "#/$defs/logicalId"},
"actor": {
"type": "object",
"additionalProperties": false,
"required": ["type", "id"],
"properties": {
"type": {"enum": ["user", "service", "system"]},
"id": {"type": "string", "minLength": 1, "maxLength": 200}
}
},
"reason": {"type": ["string", "null"], "maxLength": 500},
"trace_id": {"type": ["string", "null"], "maxLength": 128},
"aggregate_generation": {"type": "integer", "minimum": 1},
"projection_versions": {
"type": "object",
"additionalProperties": false,
"required": ["quota_source_version", "area_policy_source_version"],
"properties": {
"quota_source_version": {"type": ["integer", "null"], "minimum": 1},
"area_policy_source_version": {"type": ["integer", "null"], "minimum": 1}
}
},
"data": {
"oneOf": [
{"$ref": "#/$defs/deviceCreatedData"},
{"$ref": "#/$defs/desiredStateData"},
{"$ref": "#/$defs/configurationData"}
]
},
"occurred_at": {"type": "string", "format": "date-time"}
},
"allOf": [
{
"if": {"properties": {"event_type": {"const": "device.created"}}},
"then": {"properties": {"data": {"$ref": "#/$defs/deviceCreatedData"}}}
},
{
"if": {"properties": {"event_type": {"const": "device.desired_state.accepted"}}},
"then": {"properties": {"data": {"$ref": "#/$defs/desiredStateData"}}}
},
{
"if": {"properties": {"event_type": {"const": "device.configuration.accepted"}}},
"then": {"properties": {"data": {"$ref": "#/$defs/configurationData"}}}
}
],
"$defs": {
"logicalId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
},
"desiredState": {"enum": ["disabled", "enabled"]},
"deviceCreatedData": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "area_id", "modality", "capabilities", "desired_state"],
"properties": {
"kind": {"const": "device_created"},
"area_id": {"$ref": "#/$defs/logicalId"},
"modality": {"enum": ["video", "radar", "contact", "button", "wearable", "other"]},
"capabilities": {
"type": "array",
"maxItems": 16,
"uniqueItems": true,
"items": {"enum": ["video_capture", "audio_capture", "spatial_rule", "telemetry"]}
},
"desired_state": {"$ref": "#/$defs/desiredState"}
}
},
"desiredStateData": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "previous_desired_state", "desired_state", "changed"],
"properties": {
"kind": {"const": "desired_state_accepted"},
"previous_desired_state": {"$ref": "#/$defs/desiredState"},
"desired_state": {"$ref": "#/$defs/desiredState"},
"changed": {"type": "boolean"}
}
},
"configurationData": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "changed", "changed_fields", "area_id"],
"properties": {
"kind": {"const": "configuration_accepted"},
"changed": {"type": "boolean"},
"changed_fields": {
"type": "array",
"maxItems": 5,
"uniqueItems": true,
"items": {
"enum": ["name", "area_id", "endpoint_ref", "credential_ref", "profile_token"]
}
},
"area_id": {"$ref": "#/$defs/logicalId"}
}
}
}
}
+33 -11
View File
@@ -1,25 +1,30 @@
# 当前实现状态 # 当前实现状态
> 快照日期:2026-08-07。只记录仓库现实与 blocker;任务实时状态到 Gitea Issue 查看。 > 快照日期:2026-08-11。只记录仓库现实与 blocker;任务实时状态到 Gitea Issue 查看。
## 当前阶段 ## 当前阶段
- 阶段:M0 指定摄像头型号准入已完成;M1 的“一实机 + 四合成源”实验室软件闭环已通过;M2 已完成控制面契约和 PostgreSQL 数据基础,但五条独立真实上游和生产 SLA 尚未验收。 - 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储及 Sense→Bell 全局审计 relay 基础。客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。
- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、对账、探活和 MediaMTX 重启重建;公共设备管理 API、认证和完整生产部署仍未实现。 - 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、可选签名 relay、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令、低基数指标和可重复 16 路容量脚本;Bell 已包含事件 v0.1 校验/不可变存储、append-only outcome,以及只服务 Sense 审计的最小 `bell-api` 和全局审计 repository,但仍没有 Brain 事件 ingress、公共管理服务/JWT、规则/Alert 或 Web/H5。
- 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。 - 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。
## 仓库现实 ## 仓库现实
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Brain/`、`Bell/` 仍只有目录占位,Bell 的首个数据库源表/视图 migration 位于根目录 `deploy/postgres/`。 - `Sense/` 已有 Go module 与 `cmd/sense-api`;`Bell/` 已有事件域 Go module 和最小 `cmd/bell-api` 内部审计 receiver;`Brain/` 仍只有目录占位。Bell/Sense migration 统一位于根目录 `deploy/postgres/`。
- Sense 设备模型使用 `modality + capabilities`,SQLite 执行 v1 migration;视频配额默认 16、允许 1~128,17/128/129、新增/启用和“降低配额不关闭已有流”均有测试。 - Sense 设备模型使用 `modality + capabilities`,SQLite 执行 v1 migration;视频配额默认 16、允许 1~128,17/128/129、新增/启用和“降低配额不关闭已有流”均有测试。
- T-009 冻结 PostgreSQL `17.10` 和 `pgx/v5 v5.10.0`,实现 `bell`/`sense` schema、NOLOGIN 权限角色、Bell Site 版本 trigger、`bell.site_quota_v1` 和 Sense PostgreSQL repository;同站点并发准入用事务级 advisory lock,配额缺失/越界/版本回退时失败关闭且不改变已有流。 - T-009 冻结 PostgreSQL `17.10` 和 `pgx/v5 v5.10.0`,实现 `bell`/`sense` schema、NOLOGIN 权限角色、Bell Site 版本 trigger、`bell.site_quota_v1` 和 Sense PostgreSQL repository;同站点并发准入用事务级 advisory lock,配额缺失/越界/版本回退时失败关闭且不改变已有流。
- Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,migration 可重放、权限断言和 8 组真实 PostgreSQL repository 集成场景通过后自动清理;现有 `D:\pgsql17\data` 和 5432 服务未被读取、停止或修改。 - T-010 增量实现 `bell.areas`、`bell.area_policy_v1`、Area 版本观察和 `sense.device_operation_outbox`;`non_imaging_only` 拒绝成像设备创建/启用,失败不改变已有设备。设备创建/期望态受理与脱敏 Outbox 同事务,相同期望态不增加 generation 但仍审计。
- MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure 与探活假 HTTP 测试。 - Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,`001`~`015` migration 可重放;Sense Outbox fencing、Bell event/global-audit repository、nonce 收据、权限、幂等冲突与不可变性测试通过后自动清理,现有 `D:\pgsql17\data` 和 5432 服务未被读取、停止或修改。
- T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。当前仍不枚举/删除孤儿。 - T-015 冻结 Bell Go 1.26.5、JSON Schema v6.0.2 和 ULID v2.1.2;Bell 拒绝上游自报平台 ID,在内部 candidate 组装后执行冻结 v0.1 schema 与六项语义断言。`bell_runtime` 只允许追加/读取 `bell.events`、`bell.event_outcomes`;Brain transport、整数事件 ID 与现有文本逻辑 ID 的跨系统映射、公共 API 和生产隐私 resolver 仍未冻结,不能把内部 factory 当成已上线入口。
- T-016 冻结 `sense-audit-relay-v1`:每批 1~100 项、1 MiB、10 秒 deadline、300 秒时钟窗、600 秒 nonce 收据、30 秒数据库 lease、1~300 秒退避。Sense 使用 `FOR UPDATE SKIP LOCKED` 和 fencing token;Bell constant-time 校验 HMAC,逐项返回 accepted/duplicate/rejected,并把全局事实追加到不可变 `bell.audit_events`。relay 默认关闭,非回环两端必须 HTTPS/TLS,key 只从仓库外文件读取。
- MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure、探活和只返回名称的受限分页枚举测试。
- T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。
- T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。 - T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。
- T-014 正式使用隔离 PostgreSQL、真实 Control API、两套 MediaMTX 和 16 个独立低码率合成 publisher,完成 17 路配额拒绝、三轮 `16 → 0 → 16` 批量收敛和固定四路故障恢复;稳定观察 `1800.1 s` / 180 次采样,最大与最终 `unconverged` 均为 0、最终在线 Path 16、帧错误 0。证据见 `docs/research/sense-16-stream-capacity.md`;不外推到真实 16 机、网络、录像、AI/GPU、64/128 路或生产 SLA。
- `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。 - `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。
- `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。 - `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。
- `docs/contracts/sense-control-v1.openapi.json` 已冻结站点作用域的设备查询、创建、修改、启停与最多 128 项批量操作;`site-quota-v1.sql` 已冻结 Bell 所有、Sense 只读的配额视图签名。T-009 已实现数据库 migration/repository;HTTP handler、认证和 Bell 管理服务尚未实现。 - `docs/contracts/sense-control-v1.openapi.json` 的 7 个站点作用域/operation endpoint 已由 T-011 实现;`sense-audit-relay-v1.openapi.json` 已由 T-016 实现。默认 SQLite 只暴露运维探针与低基数 `/metrics`,不注册业务路由或 relay;Bell 管理服务与 JWT/OIDC 尚未实现。
- T-012 把 PostgreSQL schema 提升到 v5:due row 用数据库时钟、`FOR UPDATE SKIP LOCKED`、逐项续租和 fencing token 协调;MediaMTX Path 历史归属、15 分钟孤儿快照、最多 10%/128 项安全闸、无 bypass 的本地处置命令及 `/metrics` 已实现。SQLite 明确保留单实例开发语义。
- harness coding 文档、上下文清单、Gitea Issue/PR 模板和治理脚本已接入。 - harness coding 文档、上下文清单、Gitea Issue/PR 模板和治理脚本已接入。
- Gitea 已初始化 12 个协作标签;`status/waiting` 用于依赖或外部条件未满足的未领取任务,实时可领取状态必须从 Gitea 查询,不在本文复制。 - Gitea 已初始化 12 个协作标签;`status/waiting` 用于依赖或外部条件未满足的未领取任务,实时可领取状态必须从 Gitea 查询,不在本文复制。
- T-002 已关闭架构影响型问题:首期 S2 民办寄宿学校、客户侧私有部署、ONVIF/RTSP、NVIDIA 主路径、自研 Bell、Web/H5 与客户侧证据存储等边界已批准。 - T-002 已关闭架构影响型问题:首期 S2 民办寄宿学校、客户侧私有部署、ONVIF/RTSP、NVIDIA 主路径、自研 Bell、Web/H5 与客户侧证据存储等边界已批准。
@@ -40,6 +45,14 @@ Windows:
go -C Sense run ./cmd/sense-api go -C Sense run ./cmd/sense-api
``` ```
Bell 事件域和内部审计 receiver 验证:
```powershell
go -C Bell test ./...
go -C Bell vet ./...
go -C Bell build ./...
```
跨平台直接验证: 跨平台直接验证:
```powershell ```powershell
@@ -54,7 +67,16 @@ python scripts/validate_harness_governance.py
./scripts/test_postgres.ps1 -PgRoot D:\pgsql17 ./scripts/test_postgres.ps1 -PgRoot D:\pgsql17
``` ```
Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz` 与 `/readyz` 运维探针;它们不代表摄像头或 M1 里程碑健康。MediaMTX 获取、校验和独立启动方法见 `Sense/README.md`。 本机 16 路软件容量预检与正式验证:
```powershell
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -PreflightOnly
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -OutputPath (Join-Path $env:TEMP 'yovision-t014-formal.json')
```
只有默认 30 分钟且输出 `formal_eligible=true` 的单次完整运行可作正式证据;`-ObservationMinutes 1` 只用于 smoke。
Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz`、`/readyz` 运维探针和低基数 `/metrics`;探针不代表摄像头或 M1 里程碑健康。MediaMTX 获取、校验和独立启动方法见 `Sense/README.md`。
## 当前 blocker / 待确认 ## 当前 blocker / 待确认
@@ -65,12 +87,12 @@ Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz` 与 `/readyz` 运维探
- 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。 - 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。
- 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。 - 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。
- Python/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结;Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。 - Python/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结;Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。
- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、登录角色与备份方案。 - 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-016 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、最小权限登录角色、外部 Control API/relay key 文件、TLS 证书与备份方案。
- 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。 - 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。
## 下一步 ## 下一步
下一步按 Gitea 流程建立 Sense Control API v1 实现任务:基于 T-009 PostgreSQL repository 实现认证 tenant 上下文、设备 handler、幂等收据、ETag/cursor 和批量操作。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-006 的合成结果不解除 T-007,也不形成容量或生产 SLA 承诺。实时领取状态仍以 Gitea 为准。 客户网络仍未提供,T-013 WireGuard 继续后置。T-015/T-016 已分别落地 Bell 不可变事件存储和 Sense 全局审计 relay;下一项应独立冻结 Brain→Bell 业务事件 ingress,不把规则/Alert 或公共管理 API 混入。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-014~T-016 不解除 T-007/T-013,也不形成真实多路或生产 SLA 承诺。
## 已知风险 ## 已知风险
+1 -1
View File
@@ -85,7 +85,7 @@ mapper 的完整实现规格。左列出处见《07》§1。
已实测(`REJECT` = schema 能拦,`ACCEPT` = 拦不住): 已实测(`REJECT` = schema 能拦,`ACCEPT` = 拦不住):
schema **能**拦:省略必填键 / 非法 ULID / 顶层多余字段 / outcome 越界 / `config_version` 空串 / `snapshot_uris` 空数组 / 负 latency / kind 大写。 schema **能**拦:省略必填键 / 非法 ULID / 顶层多余字段 / outcome 越界 / `config_version` 空串 / 负 latency / kind 大写。`snapshot_uris` 空数组对非成像事件是合法值,见修订记录、雷达示例和 schema 的 `minItems: 0`。
schema **拦不住**,需在 mapper 与平台入口各加一道断言: schema **拦不住**,需在 mapper 与平台入口各加一道断言:
+109
View File
@@ -0,0 +1,109 @@
# Sense 本地 16 路容量与批量收敛基线
> T-014 正式证据,运行日期:2026-08-10。本文只证明固定主机、固定版本和低码率合成负载下的本地软件基线,不是生产 SLA。
## 1. 结论
在 24 个逻辑处理器、15.78 GiB 内存的 Windows 主机上,提交 `d029067aa750ad75a2d79ef8e6c51b6bad55096d` 使用隔离 PostgreSQL、真实 Sense Control API、两套独立 MediaMTX 和 16 个独立 FFmpeg publisher 完成正式验收:
- 16 个 disabled 视频设备创建后,三轮 16 项批量操作全部成功,生产 MediaMTX 配置 Path 数按 `16 → 0 → 16` 收敛。
- 16 路 enabled 后,第 17 路 enabled 创建稳定返回 `quota_exceeded`,设备台账仍为 16 项;disabled 项不冒充已占用视频通道。
- 同时停止固定第 5~8 路后,精确 4 路受影响、其余 12 路保持在线;恢复后 5.1 秒回到 16 路在线和 `unconverged=0`。
- 正式稳定窗口为 1800.1 秒,每 10 秒绝对节拍采样,共 180 个样本;最大与最终未收敛数均为 0,最终在线 Path 为 16,入站帧错误增量为 0。
因此,默认 16 路的 Sense/PostgreSQL/MediaMTX 控制面与拉流闭环具备可重复的本地软件基线。该结论不证明 16 台真实摄像头、客户网络、录像、下游观看、AI 解码/推理、GPU、64/128 路分片或生产 SLA,也不解除 T-007/T-013。
## 2. 固定环境与负载
| 项目 | 正式值 |
| --- | --- |
| 仓库提交 | `d029067aa750ad75a2d79ef8e6c51b6bad55096d` |
| Sense 二进制 SHA-256 | `e6fa01991152cf7e9f2a1e420422a12d474dfe7be10530ff2924671d46cc7871` |
| Go | `go1.26.5 windows/amd64`(在 `Sense/` 模块上下文读取) |
| PostgreSQL | `17.10`,单次运行隔离临时集群 |
| MediaMTX | `v1.19.3` |
| MediaMTX Windows amd64 ZIP SHA-256 | `5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe` |
| MediaMTX EXE SHA-256 | `1cda85249312cb9463f9f94c5a712b9f160c9af3fd9490f0d4723911d7880e05` |
| FFmpeg | `8.1.2-full_build-www.gyan.dev` |
| 主机 | Windows,24 logical processors,15.78 GiB memory |
| 单路夹具 | 640×360、10 fps、H.264、无音频、无人物 |
| 发布方式 | 16 个独立 FFmpeg 进程,各自循环预编码夹具并以 `-c copy` 发布独立 RTSP Path |
预编码 copy 发布是为了不把 16 路软件编码负载混入 Sense/MediaMTX 基线。它仍产生 16 个可独立停止和恢复的发布进程,但不能代表真实摄像头编码器、复杂 GOP、高码率、音频或公网抖动。
## 3. 运行方法与安全边界
从仓库根目录执行:
```powershell
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -PreflightOnly
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -OutputPath (Join-Path $env:TEMP 'yovision-t014-formal-final.json')
```
调试可显式传入 `-ObservationMinutes 1`,但输出固定标记 `formal_eligible=false`,不能作为正式验收。
脚本在系统临时目录生成媒体、Sense 二进制、凭据文件和 PGDATA,只绑定随机回环端口;migration 读取 `001`~`011` 并重复回放,不读取或修改 `D:\pgsql17\data`。结果 JSON 不包含 token、DSN、临时端口、设备 ID、Path/source URI 或凭据引用。正式退出后复查 session、FFmpeg、MediaMTX 和 Sense 数量均为 0,本机现有 5432 listener 前后未变化。
## 4. 功能与时序结果
| 门禁 | 正式结果 |
| --- | ---: |
| 16 个独立 publisher 就绪 | 7.6 s |
| 首轮 enable batch | `succeeded`,16 项,请求 0.041 s |
| 首轮 enable 收敛 | 3.1 s |
| 第 17 路 enabled | 拒绝,`quota_exceeded` |
| disable batch | `succeeded`,16 项,请求 0.015 s |
| disable 收敛 | 1.0 s |
| 第二轮 enable batch | `succeeded`,16 项,请求 0.046 s |
| 第二轮 enable 收敛 | 3.1 s |
| 配置 Path | `16 → 0 → 16` |
| 四路故障检测 | 1.0 s,影响 4 路,其余 12 路在线 |
| 四路恢复 | 5.1 s,最终未收敛 0 |
批量启停使用真实 `/api/v1/sites/{site_id}/devices:batchDesiredState`,每轮提交前重新读取各设备最新 ETag;不能用创建响应中的旧 ETag 与后台调和竞速。每轮 operation 必须为 `succeeded` 且 16 个逐项结果全部成功。
## 5. 30 分钟稳定性与资源观测
| 指标 | 正式结果 |
| --- | ---: |
| 观察时长 | 1800.1 s |
| 采样节拍 / 样本 | 10 s / 180 |
| 最大未收敛数 | 0 |
| 最终未收敛数 | 0 |
| 最终在线 Path | 16 |
| 聚合入站码率 | 15.443 Mbps |
| 入站帧错误增量 | 0 |
| Sense CPU 总时间 | 5.594 s |
| Sense working set 峰值 | 69.99 MiB |
| Sense private bytes 峰值 | 57.58 MiB |
| Sense handle 峰值 | 228 |
| 生产 MediaMTX CPU 总时间 | 26.812 s |
| 生产 MediaMTX working set 峰值 | 45.27 MiB |
| 生产 MediaMTX private bytes 峰值 | 79.23 MiB |
| 生产 MediaMTX handle 峰值 | 295 |
| PostgreSQL 业务库连接峰值 | 3 |
| PostgreSQL 数据库大小 | 8.90 MiB |
归一化 CPU 平均值与峰值在 24 逻辑处理器主机上四舍五入到三位小数后为 0.000%,因此报告保留进程 CPU 总秒数作为低负载证据;不能据此推导通用硬件下限。
## 6. 失败记录与工具修正
正式结论只取最后一次完整成功运行。研发过程中保留以下失败,不拼接为成功证据:
1. MediaMTX 发布 ZIP 与解压后 EXE 的 SHA-256 不同,脚本分别冻结并校验两者。
2. disabled 设备不消耗视频通道;第 17 路门禁改为在 16 路 enabled 后创建 enabled 设备,符合实际配额语义。
3. PowerShell 读取 `application/problem+json` 时可能得到 byte array;脚本显式按 UTF-8 解码后校验稳定错误码。
4. 把 `pg_ctl start` 接入 PowerShell 输出管道会让 PostgreSQL 继承管道句柄并阻塞;启动改为直接执行并检查退出码。
5. Windows 退出时 `sense-api.exe` 曾短暂持有文件锁;清理改为共享截止时间内终止全部进程、完成异步输出读取、释放 `Process` 句柄并重试删除。
6. “固定睡 10 秒再查询”会把查询耗时累计进采样间隔,30 分钟不足 180 个样本;改为按绝对时间点调度第 1~180 个样本。
7. 根目录 Go launcher 为 1.23.0,但 `Sense/go.mod` 冻结并实际构建使用 1.26.5;版本证据改为在 `Sense/` 模块上下文读取。
8. 首轮 batch 曾复用创建响应 ETag,与后台调和的资源版本更新竞速;现在三轮 batch 都在提交前读取最新 ETag,失败诊断只输出状态/错误码计数。
每次失败都没有生成正式成功 JSON,且相关临时 PostgreSQL、媒体和 Sense 进程已停止,单次 session 已清理。
## 7. 后续验收
- T-007 仍需至少 5 条独立真实摄像头上游与现场网络,才能形成真实多路故障隔离和生产试点证据。
- T-013 等客户网络拓扑、地址规划和部署权限具备后,再验证 WireGuard 与断网补传。
- 64/128 路必须在后续任务中分别验证媒体分片、带宽、解码、AI/GPU、证据存储和单分片故障域;本报告不外推这些结论。
- 进入 M3 前应优先冻结并实现 Bell 事件不可变存储,以及 Sense Outbox 到 Bell 全局审计的 relay 边界。
+65
View File
@@ -0,0 +1,65 @@
# Sense 对账与孤儿处置 Runbook
本文适用于 PostgreSQL v5(migration `001`~`011`)与 MediaMTX `v1.19.3`。SQLite 仅用于单进程开发,不具备本 runbook 的多实例 fencing 或孤儿处置语义。
## 观察入口
- `GET /healthz`:进程存活。
- `GET /readyz`:数据库 schema 和权限前置检查已完成。
- `GET /metrics`:低基数进程指标;不包含 tenant、Site、device、Path 或 URI 标签。
- 日志中的 `background convergence error`:只输出稳定、脱敏错误,不输出 stream URI 或凭据。
重点指标:
- `sense_reconcile_devices{state="unconverged"}`:未收敛的 enabled 视频设备数。
- `sense_reconcile_items_total{result="lease_lost"}`:worker 在执行前或提交时失去 fencing token 的次数;失去租约的 worker 不得继续产生外部变更。
- `sense_orphan_paths{classification="owned_stale|unowned"}`:最近一次成功扫描的两类差异。
- `sense_orphan_cleanup_blocked_total`、`sense_orphan_cleanup_items_total`:安全闸阻断和人工处置结果。
## 多实例调和
每个实例应注入唯一、稳定且不含客户信息的 `SENSE_INSTANCE_ID`。PostgreSQL 使用数据库时钟、短事务、`FOR UPDATE SKIP LOCKED` 和随机 fencing token 领取 due row;批量等待中的每一项在调用 ONVIF/MediaMTX 前续租。默认租期 30 秒、单项 deadline 20 秒;operation timeout 必须严格短于租期。
发现 `lease_lost` 时先检查实例 ID 是否重复、数据库时钟和外部调用延迟。不要通过延长到超过 5 分钟或关闭 fencing 规避问题;先定位超时,再在变更评审后同时调整租期和单项 deadline。
## 生成孤儿报告
在已安装 v5 migration、能访问同一 PostgreSQL 和目标 MediaMTX 的受控运维主机执行:
```powershell
$env:SENSE_DB_DRIVER = 'postgres'
$env:SENSE_DB_DSN = '由部署环境私下设置'
$env:SENSE_MEDIAMTX_URL = '受控 MediaMTX API 地址'
go -C Sense run ./cmd/sense-orphan -mode report
```
命令只输出 `scan_id`、计数、安全闸结论和过期时间。分类含义:
- `owned_stale`:Sense 有历史 Path 归属记录,但当前没有同一设备继续声明该 Path;可进入受控候选。
- `unowned`:没有 Sense 归属证据;可能属于人工或其他系统,永远只报告。
- 当前设备仍声明的 Path 不计入 finding。
不要因为名称相似把 `unowned` 手工改成 owned。先调查其创建者和用途;需要接管时应走独立、可审计的迁移任务。
## 执行受控处置
仅当报告显示 `safety_allowed=true`,并在 15 分钟有效期内由授权运维人员执行:
```powershell
go -C Sense run ./cmd/sense-orphan -mode apply `
-scan-id 'scan_...' `
-actor 'operator-id' `
-confirm 'DELETE scan_...'
```
执行前程序会重新枚举 Path、重新读取归属并重新计算:候选必须仍是报告集合的子集、数量 1~128,且满足 `候选数 × 100 <= 当前 Path 总数 × 10`。过期、比例超限、归属变化、确认不匹配或租约冲突都会整批零删除;没有 `force`/bypass。删除逐项记入 PostgreSQL,成功项重复执行会跳过,失败项在快照仍有效且重新计算仍通过时可重试。
## 故障与恢复
- 扫描失败:不产生可执行快照;修复数据库/MediaMTX 连通性后重新 `report`。
- `ratio_exceeded`:停止自动化,核对 MediaMTX 实例/分片是否选错、台账是否缺失或发生大面积配置漂移;不得拆小批次规避 10% 闸。
- 部分删除失败:保留输出与数据库处置事实,修复 MediaMTX 后用同一 scan ID 重试;超过 15 分钟必须重新报告和审批。
- 误删怀疑:立即停止 apply。数据库期望态仍是来源;当前设备声明的 Path 会由调和器重建。无当前设备声明的历史 Path 不自动恢复,应根据变更记录人工确认来源。
- 回滚 Sense 二进制:先停用 `SENSE_ORPHAN_SCAN_ENABLED` 并停止所有新实例,再回滚;v5 表和已记录事实保留,不执行破坏性 down migration。
本流程只处置 MediaMTX 配置 Path,不删除设备、录像、证据对象或 Bell 数据。
+106
View File
@@ -0,0 +1,106 @@
---
id: T-010
title: 建立 Area 准入投影与设备操作审计 Outbox 基础
phase: 2
deps: [T-009]
status: DONE
created: 2026-08-07
issue: 35
context_ref: 0b40cf16c3e72f4670c33a497e7d533d663ba135
claim_branch: claims/T-010
work_branch: agent/codex/T-010
write_paths:
- docs/tasks/T-010.md
- docs/contracts/
- deploy/postgres/
- scripts/test_postgres.ps1
- Sense/internal/device/
- Sense/internal/store/
- Sense/README.md
- docs/04-architecture.md
- docs/06-tasks.md
- docs/api.md
- docs/current-state.md
- tests/test_postgres_contract.py
- tests/test_area_audit_contract.py
---
## 问题 / 背景
T-008 已要求 Sense Control API v1 的设备创建携带 `area_id`,并冻结 `area_policy_unavailable` / `area_policy_denied` 错误语义;T-009 只实现了 Site 配额投影,没有冻结或实现 Area 策略投影。直接实现 HTTP handler 会让必填 Area 只能依赖临时数据或跳过隐私准入。
架构同时要求高风险设备写操作与持久化审计 Outbox 在 Sense 本地事务内一起提交,不能先改期望态再尽力记录日志。当前 repository 没有 Outbox;跨系统 relay 的字段、签名、确认与留存也尚未冻结。本任务先补齐 API 实现所需的 Area 准入和本地审计事务基础,不顺带实现 HTTP 或 Bell relay。
## 关联需求与交互(如适用)
- 用户故事:US-008、US-010。
- 交互清单:IX-016;`non_imaging_only` Area 不允许新增或启用成像设备,既有设备不得被静默停用。
- 相关公共契约:`docs/contracts/sense-control-v1.openapi.json`;本任务新增 Area 只读投影和本地设备审计事件契约,不修改已确认 Sense/Bell HTML 原型。
## 方案
1. 冻结 `bell.area_policy_v1` 六列只读视图:`tenant_id/site_id/area_id/capture_policy/source_version/source_updated_at`。Bell 源表只接受 `video_allowed | non_imaging_only`,Area 的 tenant、ID 和所属 Site 不可静默改写,业务更新由 trigger 单调递增版本。
2. 以增量 PostgreSQL migration 扩展 T-009:Bell 新增 Area 源表/视图;Sense 设备记录 `area_id` 和所用 Area 策略版本,并持久化每个 Area 已观察的最高版本。`sense_app` 只能读取视图,不能读写 Bell 源表或 trigger。
3. PostgreSQL repository 对所有新设备要求非空且匹配 tenant/site 的 Area;非成像设备可进入两种策略,具有 `video_capture` 能力的设备在创建时和从 disabled 切到 enabled 时都必须重读策略。投影缺失、非法、版本回退或不可读时失败关闭;策略为 `non_imaging_only` 时稳定拒绝成像变更。
4. Area 校验先于 Site 配额校验,使用事务级 advisory lock 串行化同 tenant/Area 的投影版本观察;已有 Site 配额锁和 16/128 路语义保持不变。策略或配额变化不得自动修改已有设备。
5. 冻结本地 `sense.device_operation_outbox` 事件结构并在 PostgreSQL repository 的设备创建、期望态受理路径中同事务写入。事件只保存逻辑 ID、主体、原因、trace、generation、所用投影版本和脱敏动作数据;不得包含 endpoint、credential、密码、完整 URI、token 或 MediaMTX 配置。
6. 相同期望态的重复受理不增加 generation,但仍产生一条独立审计事实。Outbox 写入失败必须回滚对应设备变更;默认内部调用使用受控 `system/sense` 主体,后续 T-011 HTTP handler 必须用认证结果覆盖主体上下文。
7. SQLite 继续作为 M1 实验室路径,不宣称具备生产 Area/Outbox 一致性。后续公共控制 API 只在 PostgreSQL 生产路径启用;本任务不为 SQLite 复制 Bell 真相或伪造策略。
8. 使用 `D:\pgsql17` 二进制启动随机回环端口的隔离 PostgreSQL 17.10 集群,验证 migration 重放、最小权限、tenant/site/Area 隔离、策略允许/拒绝、缺失/回退失败关闭、并发版本观察、Outbox 原子性与脱敏。
## 不可变约束
- Area 策略枚举固定为 `video_allowed | non_imaging_only`;本阶段以 `video_capture` capability 作为成像判定,不新建第二套 `captures_image` 真相。
- `bell.area_policy_v1` 的六列顺序、含义和 Bell 所有权是 v1 契约;Sense 只有 Bell schema `USAGE` 与该视图 `SELECT`,没有 Bell 源表读取或任何写权限。
- 同库只读视图是实时读取,不以 `source_updated_at` 的年龄误判长期未变化的合法 Area 为过期;缺失、非法值、版本回退和读取失败均视为投影不可用。未来改成缓存或远程投影时必须另行冻结 freshness 规则。
- PostgreSQL 新建设备必须具有非空 `area_id` 且与认证 tenant、路径 Site 对应。`non_imaging_only` 允许非成像设备,但拒绝成像设备的创建,即使创建时为 disabled;启用已有成像设备时必须再次检查。
- 策略不可用或拒绝只阻止相关新写入,不删除、不移动、不停用已有设备。策略与已有成像设备冲突时仍由 Bell 走显式迁移或取消流程。
- Site 视频配额默认 16、有效范围 1~128;Area 校验不能绕过、扩大或替代配额检查。
- 设备创建和期望态受理必须与 Outbox 同事务;相同期望态不增加 generation但仍审计。失败事务不得残留设备、状态、投影观察或 Outbox 半成品。
- 本地事件契约不等于 Bell relay 协议;transport、签名、批量确认、重放窗口和留存不得在实现中自行猜测。
- PostgreSQL 仍固定为 `17.10`,使用现有 `D:\pgsql17\bin`;自动验证不得读取、停止或修改 `D:\pgsql17\data` 和现有 5432 服务。
## 验收要点
- 任务相关验证:`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 完成隔离集群、增量 migration 连续重放、SQL 权限断言和真实 repository 集成测试;`python -m unittest discover -s tests -p "test_area_audit_contract.py"` 校验投影/事件契约、权限负例和敏感字段禁入。
- 完整门禁:运行 `./init.ps1`、`python scripts/validate_agent_context.py`、`python -m unittest discover -s tests -p "test_*.py"`、`python scripts/validate_harness_governance.py`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` 和 `git diff --check`,全部通过。
- 人工 / 设备验收:不需要摄像头、UI 或客户现场;核对隔离脚本未引用现有 data 目录、5432 listener 前后不变,失败响应和 Outbox 不含秘密或完整连接信息。
- 构建产物:Area/审计契约、增量 PostgreSQL migration、Sense Area 准入与 Outbox 事务代码、集成/静态测试和同步文档。
## 边界(不改什么)
不实现 Sense HTTP handler、Bearer 认证、幂等收据、ETag/cursor、批量 operation、Bell Area 管理服务/UI、Outbox relay、签名/确认/留存、Area 迁移处置、SQLite 生产对等语义、Brain、MediaMTX、16 路容量测试或 T-007 现场验收;不修改已确认 HTML 原型。
## 协作约束
- 责任 Agent:由 dispatcher 分配。
- 唯一写入者:同责任 Agent。
- 委派:默认不启用。
- Gitea:任务文件先进入默认分支,再创建唯一 Issue 并回填编号;领取时记录 `context_ref`、claim / 工作分支和全部允许写路径。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
## 执行记录
### 2026-08-07 完成 Area 准入与本地审计事务基础
- 新增 `005`~`007` 增量 migration:Bell Area 源表、不可变 tenant/Site/ID 与单调版本 trigger、六列 `bell.area_policy_v1`,以及 Sense Area 观察状态、设备投影版本、脱敏设备操作 Outbox 和最小权限;`001`~`007` 连续执行两遍通过。
- 冻结 `video_allowed | non_imaging_only` 和本地设备审计 JSON Schema。PostgreSQL 新建设备必须关联匹配 tenant/Site 的 Area;成像设备在创建(包括 disabled)和启用时检查策略,缺失/非法/回退失败关闭,策略变化不静默修改已有设备。
- Repository 按 Area→Site 固定顺序使用 transaction-scoped advisory lock;设备创建/期望态受理与 Outbox 同事务。相同期望态不增加 generation 但生成独立审计事实;Outbox 故障会回滚设备创建或状态变化,payload 不含 endpoint、credential、path、密码、完整 URI 或 MediaMTX 配置。
- 新增 9 个 T-010 PostgreSQL 集成场景,总计 18 个 `TestPostgres*`:策略允许/拒绝、缺失/版本回退、并发观察、跨 tenant 隔离、启用重检、既有设备保持、主体上下文/脱敏、创建与期望态两类 Outbox 回滚、过宽 Area 源权限拒绝。`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 通过并清理随机回环端口临时集群,现有 `D:\pgsql17\data` 与 5432 listener 未被读取、停止或修改。
- `./init.ps1` 通过;`python -m unittest discover -s tests -p "test_*.py"` 共 40 项通过,其中 T-010 静态契约 7 项;上下文/治理校验、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` 和 `git diff --check` 全部通过。不需要摄像头、UI 或客户现场验收。
- SQLite 继续作为 M1 实验室路径,不宣称具备生产 Area/Outbox 一致性;T-011 公共控制 API 必须只在 PostgreSQL 路径启用。Outbox relay 的 transport、签名、确认、重放窗口和留存仍是后续任务边界。
### 2026-08-07 领取任务
- dispatcher `ila` 将 Issue #35 分配给 `codex`;`context_ref` 为 `0b40cf16c3e72f4670c33a497e7d533d663ba135`,claim 为 `claims/T-010`,工作分支为 `agent/codex/T-010`。
- 已读回 Issue `status/doing`、结构化 CLAIM 与两个分支 SHA 一致;接受 frontmatter 中全部 `write_paths`,当前没有其他活跃任务占用这些路径。
### 2026-08-07 Gitea 映射
- 任务规格先合入默认分支,再创建唯一主 Issue #35;本提交只回填双向映射,映射合入并读回前不领取任务。
### 2026-08-07 任务定义
- 项目负责人要求创建并立即实施 T-010;任务依赖已完成的 T-009,优先补齐 Area 与审计事务基础,再进入 Sense Control API v1 handler 实现。
- 采用已批准的两值 `capture_policy`,首期同 PostgreSQL 实例内使用 Bell 所有的版本化只读视图;Outbox 只冻结本地事件与原子写入,跨系统 relay 保持后续任务边界。
+122
View File
@@ -0,0 +1,122 @@
---
id: T-011
title: 实现 Sense Control API v1 与 PostgreSQL 一致性边界
phase: 2
deps: [T-010]
status: DONE
created: 2026-08-07
issue: 39
context_ref: 6402d4384d5c2b1c0f3852858c3cf0d63d948262
claim_branch: claims/T-011
work_branch: agent/codex/T-011
write_paths:
- docs/tasks/T-011.md
- docs/contracts/
- deploy/postgres/
- scripts/test_postgres.ps1
- init.ps1
- init.sh
- Sense/api/
- Sense/cmd/sense-api/
- Sense/internal/auth/
- Sense/internal/config/
- Sense/internal/controlapi/
- Sense/internal/device/
- Sense/internal/mtx/
- Sense/internal/reconcile/
- Sense/internal/store/
- Sense/README.md
- docs/00-ai-start-here.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/06-tasks.md
- docs/api.md
- docs/current-state.md
- tests/test_postgres_contract.py
- tests/test_sense_control_implementation.py
---
## 问题 / 背景
T-008 已冻结 Sense Control API v1 的 7 个 endpoint,T-009/T-010 已提供 PostgreSQL Site 配额、Area 策略、设备准入和本地审计事务基础;当前 `sense-api` 仍只暴露 `/healthz`、`/readyz`,没有认证 tenant 上下文、HTTP handler、幂等收据、并发版本、稳定游标或批量 operation。若继续让实验室工具直写 repository,Bell 和受控集成方没有可发布、可审计的生产控制入口。
现有调和器还只选择 enabled 设备,停用写入不会删除该设备的已知 MediaMTX path。公共启停 API 上线前必须补齐这一条确定性收敛路径;它只删除台账中该设备的精确 path,不扩大为孤儿枚举或批量清理。
## 关联需求与交互(如适用)
- 用户故事:US-001、US-002、US-008、US-009、US-010。
- 交互清单:IX-001~IX-004、IX-013~IX-016、IX-019、IX-020;本任务实现已确认 Sense 原型所依赖的设备控制后端,不修改 HTML 页面布局。
- 公共契约:`docs/contracts/sense-control-v1.openapi.json`;投影 v1 与审计 v1 保持兼容。
## 方案
1. 使用已冻结的 `oapi-codegen v2.8.0` 从仓库 OpenAPI 生成 Go 类型和 `net/http` server glue,生成物进入标准漂移门禁;业务校验、认证和事务逻辑留在手写层,不修改生成文件。
2. Control API 通过 `SENSE_CONTROL_API_ENABLED=true` 显式开启,且只允许 `SENSE_DB_DRIVER=postgres`。默认 SQLite 和无认证的 `/healthz`、`/readyz` 继续可用,但不得注册 `/api/v1` 业务路由。
3. 定义可替换 `Authenticator` port;首期 `static-sha256` 适配器从仓库外 JSON 文件加载主体、tenant、Site scope、`sense.devices.read|write` 权限和 Bearer token SHA-256,不保存、记录或比较明文 token。原始文件只在启动时加载,变更需受控重启;JWT/OIDC/Bell 会话验证属于后续适配器。
4. Control API 开启时必须从独立仓库外文件读取至少 32 字节的 cursor HMAC key。游标绑定 tenant、Site、全部过滤条件和最后一项 `(created_at,id)`,使用 URL-safe 编码和 HMAC-SHA256;篡改、跨筛选或跨作用域重放返回 `400 invalid_request`。
5. PostgreSQL 增量 migration 为设备增加 write-only `profile_token` 和独立 `resource_version`,并创建 24 小时幂等收据、批量 operation/逐项结果表。ETag 是由设备 ID 与 `resource_version` 生成的强 opaque 值;只在配置或期望态表示实际改变时增加,后台 actual/reconcile 更新不使操作员写入无故冲突。所有设备响应设置 `Cache-Control: no-store`。
6. 创建和批量请求的作用域固定为认证主体 + tenant + Site + `operationId` + `Idempotency-Key`;数据库只保存作用域摘要、规范化请求摘要和脱敏响应快照。同作用域同 body 在至少 24 小时内返回首次状态码、body、ETag/Location 和 trace ID;同 key 不同 body 返回 `409 idempotency_conflict`。收据与首个业务结果同事务提交,过期记录只做有界机会清理。
7. 实现列表、创建、读取、merge patch、单项期望态、最多 128 项批量期望态和 operation 查询。tenant 只来自认证上下文;超出主体 Site scope、跨 tenant 和不存在统一 `404`,已知作用域内缺权限返回 `403`。列表按 `created_at ASC,id ASC`,默认 50、最大 100;读取只回显 configured 布尔值,永不回显 endpoint、credential 或 profile token。
8. PATCH 与期望态更新在行锁内比较 `If-Match`:缺失 `428`、格式非法 `400`、不匹配 `412`。Area 变更对成像设备重新执行 Area 准入;启用继续按 Area→Site 固定锁顺序检查策略和配额。重复提交相同期望态不增加 generation/resource version,但仍写一条独立审计事实。
9. 批量请求内所有重复 `device_id` 对应项均拒绝为 `invalid_request`,其他项继续;每项用 savepoint 隔离预期业务失败,成功项持久化。首版在请求事务内完成“期望态受理”并返回已完成 operation,`succeeded` 只表示期望态和审计已持久化,不表示媒体实际态已经收敛。
10. 保留审计 v1 文件不变,新增向后兼容的设备审计 v2 契约,增加 `device.configuration.accepted` 脱敏事实;PATCH 与该事实同事务,payload 只记录安全的 changed-fields/Area 逻辑 ID。创建和期望态既有事件继续同时满足 v2;Outbox relay 仍不在本任务实现。
11. 调和器对 disabled 视频设备执行其精确 `path_name` 的幂等删除,再把 observed generation 标记为当前代、actual state 置为 offline;不枚举 MediaMTX、不删除未知 path、不新增设备删除 API。
## 不可变约束
- 数值边界:设备列表默认 50、最大 100;批量最少 1、最多 128;请求 body 最大 1 MiB;幂等收据至少保存 24 小时;Site 视频配额默认 16、有效范围 1~128。
- 认证与隔离:Bearer token 至少 128 bit;静态文件只存 64 位小写十六进制 SHA-256;摘要比较使用 constant-time;tenant 不接受 path/query/body 自报。Site scope 不匹配、跨 tenant 和不存在不得产生可区分响应。
- 幂等与并发:规范化 body 使用确定性 JSON;收据、首个业务写和 trace/响应快照原子提交。ETag 只作为写并发令牌,不作为可缓存快照;`If-Match: *` 和多 ETag 不接受。
- 资源模型:设备 ID 与 operation ID 由服务端生成并满足冻结 pattern;modality/capabilities/serial 在 v1 PATCH 中不可修改。视频或 `video_capture` 创建设备必须有 endpoint 与 credential ref;endpoint 禁止 userinfo。
- 投影失败:Area 缺失/非法/版本回退统一为 `503 area_policy_unavailable`,策略拒绝为 `422 area_policy_denied`;配额缺失/非法/回退分别使用冻结错误。失败只阻止相关写,不改变已有设备或流。
- 秘密边界:token、cursor key、endpoint、credential、profile token、DSN、完整流 URI 和 MediaMTX 配置不得进入 Git、Issue/PR、普通响应、Problem、日志、游标、幂等作用域明文或审计 payload。静态认证和 cursor key 文件必须位于仓库外。
- 既有契约:不修改 Sense Control OpenAPI v1 的路径、字段、状态码和错误枚举;不原地扩展严格审计 v1 枚举,新增 v2 文件。Bell 继续拥有 Tenant/Site/Area/RBAC/配额;Sense 不读取 Bell 源表或写 Bell schema。
- 恢复:新 migration 前向可重放,不提供自动破坏性 down;新代码部署前先装 migration,回滚旧二进制可忽略新增列/表。公共 API 可通过关闭 feature flag 回退,已写设备/Outbox/operation/收据不得被自动删除。
## 验收要点
- 任务相关验证:Go 单元/HTTP 测试覆盖认证失败、权限、tenant/Site 隐藏、7 个 endpoint、严格 JSON、敏感字段不回显、cursor 篡改/绑定、ETag 428/412、创建和批量幂等、重复项、稳定错误与 disabled path 删除;`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 覆盖真实 migration、收据原子性、并发重复请求、逐项 savepoint、operation 可见性、审计 v2 和权限。
- 静态契约:`python -m unittest discover -s tests -p "test_sense_control_implementation.py"` 与既有全部契约测试通过;生成后工作树无漂移,OpenAPI v1 和审计 v1 指纹语义不被改写。
- 完整门禁:`./init.ps1`、`python scripts/validate_agent_context.py`、`python -m unittest discover -s tests -p "test_*.py"`、`python scripts/validate_harness_governance.py`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、`git diff --check` 全部通过。
- 人工 / 设备验收:不需要摄像头或 UI;使用临时 PostgreSQL 和 fake MediaMTX/adapter 完成。本任务不解除 T-007 真实多路现场门禁,也不形成 16/128 路容量承诺。
- 构建产物:生成的 Control API glue、认证/handler/store、增量 migration、审计 v2 schema、外部配置示例(只含占位符)、本机隔离集成测试和同步运维文档。
## 边界(不改什么)
不实现 Bell 管理服务或 UI、JWT/OIDC、静态主体热加载、TLS termination、WAF/公网暴露、Outbox relay/签名/留存、设备删除、孤儿枚举、证据 API、Brain、WireGuard、容量压测或 T-007 现场验收;不把 Control API 下放到 SQLite,不修改已确认 Sense/Bell HTML 原型。
## 协作约束
- 责任 Agent:由 dispatcher 分配。
- 唯一写入者:同责任 Agent。
- 委派:默认不启用。
- Gitea:任务文件先进入默认分支,再创建唯一 Issue 并回填编号;领取时记录 `context_ref`、claim / 工作分支和全部允许写路径。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
## 执行记录
### 2026-08-07 完成 Sense Control API v1
- 使用冻结的 `oapi-codegen v2.8.0` 从 T-008 OpenAPI 生成 Go 1.22+ `net/http` server glue(生成 SHA-256 `39a52e4f54bb1a742f58b64dcf15ce98359385236da5ccc0dae77e8ded2b218b`),实现全部 7 个 endpoint、严格 JSON/1 MiB body、稳定 Problem、write-only 字段脱敏和 `Cache-Control: no-store`;生成漂移已进入 `init.ps1`/`init.sh`。
- 新增可替换认证 port 与 `static-sha256` 私有部署适配器:外部注册表只保存 token 摘要、主体、tenant、Site scope 与两项权限,摘要 constant-time 比较;业务路由默认关闭,只能在 PostgreSQL v4 schema 与外部 32 字节 HMAC cursor key 有效时开启。SQLite 默认路径继续只暴露探针。
- 新增 `008`/`009` migration:设备 `resource_version`/write-only profile token、24 小时摘要幂等收据、持久化 batch operation/逐项结果与最小权限。创建收据与设备/审计同事务;batch 以 savepoint 隔离逐项失败,并先按设备 ID 排序锁定目标,避免相反请求顺序死锁。ETag 只在配置/期望态实际变化时前进;相同期望态仍审计但不增加 generation/resource version。
- 保留严格审计 v1 文件不变,新增 v2 后继和 `device.configuration.accepted` 脱敏事实;Area 修改重新准入。停用调和只对台账中的精确 path 做幂等删除,成功后 observed generation 收敛且 actual state 为 offline,不枚举或清理未知 path。
- `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 通过:`001`~`009` 连续重放、SQL 权限断言和 25 个 `TestPostgres*` 全绿,覆盖列表稳定分页/过滤、创建并发幂等、收据脱敏/冲突、ETag/PATCH、审计 v2、逐项 batch、operation tenant 隐藏、相反顺序批量锁与 PUBLIC 权限负例;随机回环临时集群已停止并清理,现有 `D:\pgsql17\data`/5432 listener 未读取、停止或修改。
- `./init.ps1` 通过;Python 契约/治理测试共 47 项通过;`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、生成漂移和 `git diff --check` 均通过;`go test -race ./internal/auth ./internal/controlapi ./internal/reconcile ./internal/store` 通过。不需要摄像头或 UI,本结果不解除 T-007,也不形成 16/128 路容量承诺。
### 2026-08-07 领取任务
- dispatcher `ila` 将 Issue #39 分配给 `codex`;`context_ref` 为 `6402d4384d5c2b1c0f3852858c3cf0d63d948262`,claim 为 `claims/T-011`,工作分支为 `agent/codex/T-011`。
- 已读回 Issue `status/doing`、assignee、dispatcher 发布的结构化 CLAIM 与两个分支 SHA;接受 frontmatter 全部写路径。T-007 仍为 waiting,当前没有活跃写路径冲突。
- 实现中发现标准生成入口还需同步 `docs/00-ai-start-here.md`;dispatcher 复查无活跃冲突后发布完整 CLAIM RENEWAL,本任务据此增加该精确路径。
### 2026-08-07 Gitea 映射
- 任务规格先合入默认分支,再创建唯一主 Issue #39;本提交只回填双向映射,映射合入并读回前不领取任务。
### 2026-08-07 任务定义
- 项目负责人要求创建并实施 T-011;依赖 T-010 已完成,Gitea 当前只有 T-007 因真实设备条件处于 waiting,不占用写路径。
- 选择 PostgreSQL 一致性实现而不是进程内幂等/operation 缓存;选择外部静态 SHA-256 注册表作为私有部署首版认证适配器,同时保留未来 Bell/JWT/OIDC port。
- 公共停用 API 上线前补齐精确 path 删除;该变更不扩大为孤儿清理,也不修改 T-007 门禁。
+113
View File
@@ -0,0 +1,113 @@
---
id: T-012
title: 补齐 Sense 对账安全闸、孤儿受控处置与多实例可观测性
phase: 2
deps: [T-011]
status: DONE
created: 2026-08-07
issue: 43
context_ref: 25357723a09218bae58626a9934309abcda34899
claim_branch: claims/T-012
work_branch: agent/codex/T-012
write_paths:
- docs/tasks/T-012.md
- Sense/README.md
- Sense/cmd/sense-api/
- Sense/cmd/sense-orphan/
- Sense/internal/config/
- Sense/internal/metrics/
- Sense/internal/mtx/
- Sense/internal/orphan/
- Sense/internal/reconcile/
- Sense/internal/store/
- deploy/postgres/
- scripts/test_postgres.ps1
- docs/00-ai-start-here.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/06-tasks.md
- docs/api.md
- docs/current-state.md
- docs/runbooks/
- tests/test_postgres_contract.py
- tests/test_sense_reconcile_safety.py
---
## 问题 / 背景
T-003 已实现幂等调和与持久化退避,T-011 已完成 Control API、批量期望态和停用设备的精确 Path 删除,但生产 PostgreSQL 路径仍由每个 Sense 实例独立读取同一批 due row;多实例会重复探测、重复写 MediaMTX,并可能由过期 worker 覆盖新 worker 的完成结果。当前 MediaMTX 客户端也不枚举配置 Path,因此无法区分台账、媒体配置和历史归属之间的孤儿差异。
直接把“MediaMTX 中存在、当前设备表中不存在”的所有 Path 自动删除并不安全:MediaMTX 可能包含人工或其他系统管理的 Path,Sense 也没有历史归属证据。本任务先建立可证明的所有权、只读报告、比例安全闸和受控的一次性处置流程,并提供不含租户/设备高基数标签的 Prometheus 文本指标;它是进入 WireGuard 与 16 路批量基准前的 M2 生产安全基础,不等于容量验收。
## 关联需求与交互(如适用)
- 用户故事:US-001、US-002、US-008、US-009;本任务提供接入运维所需的调和与孤儿诊断,不新增最终 Bell 管理页面。
- 交互清单:IX-003、IX-004、IX-015、IX-016 的后端运维状态基础;HTML 原型不改,正式运维中心 API/UI 留给后续契约任务。
- 相关页面 / 路由:新增低基数 `/metrics` 运维端点和本地 `sense-orphan` 命令;不修改 T-008 冻结的 `/api/v1` Control API。
## 方案
1. 增加 PostgreSQL `010`/`011` migration,把 `sense.schema_migrations` 提升到 v5:`sense.reconcile_state` 增加 owner/token/expiry 租约列;新增 MediaMTX Path 历史归属、孤儿扫描租约、扫描报告/发现项和处置结果表,并补齐 `sense_app` 最小权限、PUBLIC 拒绝与启动前置检查。migration 从现有视频设备脱敏回填 Path 归属,不保存 endpoint、credential 或 source URI。
2. 将调和 repository port 改为“领取 due row → 每项开工前续租 → 持 token 完成/失败”的 fencing 模型。PostgreSQL 使用短事务、`FOR UPDATE SKIP LOCKED` 和随机 claim token;完成、失败和续租必须同时匹配 device、owner、token 且租约未被新实例接管。SQLite 保持单进程开发语义,不宣称跨进程互斥。
3. 每项外部调和使用短于租约的 deadline;批量领取后逐项续租,等待期间已被其他实例接管的项只记录 `lease_lost`,不得执行 MediaMTX/ONVIF 变更。启用设备成功收敛时在同一 PostgreSQL 完成事务中刷新其 Path 历史归属;停用设备的既有精确删除仍不经过孤儿批量逻辑。
4. 扩展 MediaMTX 薄客户端,以官方分页 API 枚举 Path 名称;循环设置页数上限、重复页保护和缺失字段校验,只返回排序去重后的名称,永不返回或记录 source URI。现有 `EnsurePath`/精确 `DeletePath` 语义不变。
5. 新增 PostgreSQL 专用孤儿扫描器并在 `sense-api` 中默认按 60 秒执行只读扫描。单例扫描使用带 fencing token 的数据库租约;一次报告将 MediaMTX 配置分为:仍被设备当前占用、Sense 历史拥有但已失配的 `owned_stale`、从未有 Sense 归属证据的 `unowned`。`unowned` 永远只报告;扫描失败不产生可执行快照。
6. 新增本地运维命令 `sense-orphan`:`report` 只产生脱敏 JSON 摘要和 15 分钟有效的 scan ID;`apply` 必须给出该 scan ID、合法 actor 和精确确认文本。执行前重新枚举 MediaMTX、重新读取所有权并重新计算安全闸,只处理仍为 `owned_stale` 的快照项;每项结果持久化,重复 apply 幂等。
7. 孤儿删除安全闸固定为 `候选数 * 100 <= 当前 MediaMTX 配置 Path 总数 * 10`,并同时要求候选数 1~128、报告未超过 15 分钟、当前总数大于 0、当前候选是报告候选的子集。任何条件失败均整批不删除;不提供 force/bypass。删除逐项执行,部分失败可重试,但一次运行不得扩大目标集合。
8. 新增无第三方依赖的 Prometheus/OpenMetrics 文本 handler,暴露构建/实例信息、调和 run/item/lease-lost/时长/未收敛汇总,以及孤儿扫描 observed/owned-stale/unowned、blocked/deleted/failed 汇总。标签只使用固定结果枚举和经过格式校验的 instance ID,不使用 tenant、Site、device、Path、URI 或错误正文。
9. 更新 Sense 启动与文档:生成进程级 instance ID,也允许 `SENSE_INSTANCE_ID` 显式指定;配置调和租约/操作 deadline、孤儿只读扫描开关/周期和指标开关。默认 SQLite 继续可运行;孤儿持久化/处置只在 PostgreSQL v5 上启用。修正当前状态中“T-011 公共设备 API 尚未实现”的过期描述。
## 不可变约束
- 阈值 / 数值边界:调和租约默认 30 秒,单项 operation deadline 默认 20 秒且必须严格小于租约;调和 batch 最大 128。孤儿扫描默认 60 秒;报告有效期固定 15 分钟;单次处置 1~128 项;删除比例最多 10%,使用整数交叉相乘,禁止浮点边界漂移。
- 判定式 / 状态转换:只有 `media_path_ownership` 有历史记录、且当前没有任何视频设备继续声明同一 `(device_id,path_name)` 的运行时 Path 才是 `owned_stale`。未归属 Path 是 `unowned`,永不由 Sense 删除。过期或失配 fencing token 不得更新调和完成/失败,也不得产生可执行扫描报告。
- 安全边界:默认和周期任务只读扫描,绝不自动 apply;`apply` 必须二次确认并重新取运行时/数据库快照。比例闸、快照时效、上限、所有权或确认任一失败时零删除;不提供环境变量、flag 或隐藏接口绕过。完整 URI、endpoint、credential、profile token、DSN、认证 token 和错误响应正文不得进入指标、日志、报告或审计表。
- 既有契约:不修改 Sense Control OpenAPI v1、设备审计 v1/v2 和 7 个业务 endpoint;停用设备按精确台账 Path 幂等删除的行为保持。`site.max_video_channels` 默认 16、上限 128,不把 16 写成业务或数组硬上限。
- 多实例边界:PostgreSQL v5 才提供调和/孤儿租约语义;SQLite 只保留本地单实例开发,不得通过 SQLite 测试宣称生产多实例安全。租约不是长期锁,所有外部调用必须可取消并受 deadline 限制。
## 验收要点
- 任务相关验证:Go 单元测试覆盖 MediaMTX 多页/重复页/脱敏失败、租约失效时零副作用、两个 PostgreSQL store 并发只领取一次、租约过期接管与旧 token fencing、归属回填、三类孤儿判定、10% 边界、15 分钟过期、二次快照收窄、未知 Path 不删除、部分失败幂等和 metrics 无敏感/高基数标签。
- PostgreSQL 门禁:`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 连续重放 `001`~`011`、执行权限/约束断言和全部 `TestPostgres*`;随机回环临时集群自动清理,不读取、停止或修改现有 `D:\pgsql17\data`/5432 实例。
- 完整门禁:`./init.ps1`、三条 Python 治理命令、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、`go test -race ./internal/metrics ./internal/mtx ./internal/orphan ./internal/reconcile ./internal/store`、`git diff --check` 全部通过。
- 人工 / 设备验收:不需要摄像头、GPU 或客户现场;使用 fake MediaMTX 与隔离 PostgreSQL 验证。真实五路与容量结论仍由 T-007/后续 16 路基准提供。
- 构建产物:`sense-api` 的 `/metrics`、`sense-orphan` 本地命令、PostgreSQL v5 migration、运维 runbook、测试和同步文档;不生成或提交任何私有配置。
## 边界(不改什么)
不实现 Bell 运维 UI/RBAC/JWT、WireGuard、Outbox relay、设备删除 API、跨 MediaMTX 分片编排、16 路容量压测、自动修复 `unowned` Path 或 T-007 现场验收;不修改 MediaMTX 生成代码、冻结 OpenAPI/审计契约和已确认 Sense/Bell HTML 原型。指标只建立进程内 Prometheus scrape 面,不引入 Grafana dashboard、OpenTelemetry SDK 或新的第三方依赖。
## 协作约束
- 责任 Agent:由 dispatcher 分配。
- 唯一写入者:同责任 Agent。
- 委派:默认不启用。
- Gitea:任务规格先进入默认分支,再创建唯一 Issue 并回填编号;领取时记录 `context_ref`、claim / 工作分支和全部允许写路径。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。T-007 虽处于 waiting,但其未来写路径包含 `Sense/`;T-012 活跃期间不得同时恢复 T-007。
## 执行记录
### 2026-08-07 完成多实例对账安全与孤儿受控处置
- PostgreSQL schema 提升到 v5:`010`/`011` 为 due row 增加 owner/token/expiry fencing,新增不含 source URI 的 MediaMTX Path 历史归属、孤儿扫描租约、15 分钟报告、发现项和处置审计,并补齐 `sense_app` 最小权限与 PUBLIC 拒绝;启动会拒绝旧 schema 或越权运行角色。
- 调和器改为 `FOR UPDATE SKIP LOCKED` 批量领取、逐项续租、短于租期的外部调用 deadline 和带 token 的完成/失败;数据库时钟判定租约,旧 worker 不能覆盖接管者结果。成功启用在同一完成事务中刷新 Path 归属;SQLite 明确保留单进程开发语义。
- MediaMTX 薄客户端新增只返回排序去重 Path 名称的有界分页枚举,包含重复页保护且不读取/返回 source;独立孤儿扫描把差异分为 `owned_stale` 与永不删除的 `unowned`。周期任务默认只报告,人工 `sense-orphan apply` 必须使用 15 分钟内 scan ID、合法 actor 和精确确认文本,并在删除前重取库存/归属。
- 处置安全闸固定为 1~128 项且不超过当前 Path 的 10%,使用整数交叉相乘,无 force/bypass;目标只能从原快照收窄。逐项结果可审计和幂等重试,数据库外键/约束也拒绝为 `unowned` 写入删除记录。
- 新增无第三方运行时依赖的 `/metrics`:只使用构建/实例及固定结果枚举标签,覆盖调和 run/item/lease-lost/时长/未收敛和孤儿扫描/阻断/删除汇总;tenant、Site、device、Path、URI 与错误正文均不进入标签。
- `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 通过:隔离 PostgreSQL 17.10 临时集群连续重放 `001`~`011`,SQL 权限/约束断言及 29 个 `TestPostgres*` 全绿,覆盖两个 store 并发唯一领取、租约接管/旧 token fencing、归属记录、扫描租约、未知 Path 数据库拒删和处置幂等;随机端口实例已停止并清理,现有 `D:\pgsql17\data`/5432 未被读取、停止或修改。
- `./init.ps1` 通过;Python 治理/契约测试共 52 项通过,T-012 定向静态契约 12 项通过;全部 Go 测试、生成漂移、`go vet`、`go build` 与 `git diff --check` 通过;`go test -race ./internal/metrics ./internal/mtx ./internal/orphan ./internal/reconcile ./internal/store` 通过。不需要摄像头/GPU,本结果不解除 T-007,也不形成 16/128 路容量承诺。
### 2026-08-07 领取任务
- dispatcher `ila` 将 Issue #43 分配给 `codex`;`context_ref` 为 `25357723a09218bae58626a9934309abcda34899`,claim 为 `claims/T-012`,工作分支为 `agent/codex/T-012`。
- 已读回 Issue `status/doing`、assignee、dispatcher 发布的完整 CLAIM 与两个分支 SHA;接受 frontmatter 全部写路径。T-007 继续 waiting,本任务期间不恢复,当前无活跃写路径冲突。
### 2026-08-07 Gitea 映射
- 任务规格先通过 PR #42 合入默认分支,再创建唯一主 Issue #43;本次只回填双向映射,映射合入并读回前不领取任务。
### 2026-08-07 任务定义
- 项目负责人要求在 T-011 后创建并实施 T-012;当前只有 T-007 因外部真实设备条件处于 waiting,无开放 PR 或活跃 claim。
- 本任务把路线图的“对账器并发/10% 安全闸”和当前状态建议收敛为 PostgreSQL fencing、可证明 Path 所有权、只读孤儿报告、不可绕过的受控处置和低基数指标;不以未知 Path 的存在推断 Sense 有删除权。
+116
View File
@@ -0,0 +1,116 @@
---
id: T-014
title: 建立 Sense 本地 16 路批量收敛与容量基线
phase: 2
deps: [T-012]
status: DONE
created: 2026-08-08
issue: 47
context_ref: 8ebb8ced492c614106e766192c59b7f13fb4ad0f
claim_branch: claims/T-014
work_branch: agent/codex/T-014
write_paths:
- docs/tasks/T-014.md
- Sense/README.md
- Sense/scripts/t014-capacity.ps1
- docs/00-ai-start-here.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/06-tasks.md
- docs/current-state.md
- docs/research/sense-16-stream-capacity.md
- tests/test_sense_capacity_contract.py
---
## 问题 / 背景
T-006 已证明一台准入实机与四条独立合成源可以完成五路实验室闭环,T-011/T-012 已实现 PostgreSQL Control API、最多 128 项批量期望态、多实例 fencing、孤儿安全闸和低基数指标;但 M2 的默认 16 路仍只有产品配额和单元测试,没有通过真实 HTTP 控制面完成 16 路创建、批量启停、MediaMTX 收敛、局部故障恢复和资源观测。项目当前没有客户网络环境,WireGuard T-013 暂缓,不能让该外部依赖阻塞本地软件容量基线。
本任务建立可重复的 Windows 本地 16 路实验室基准。它验证当前机器、固定软件版本和低码率无人物合成负载下的 Sense/PostgreSQL/MediaMTX 控制面与媒体拉流能力,不代表 16 台真实摄像头、客户网络、存储、AI/GPU 或生产 SLA。
## 关联需求与交互(如适用)
- 用户故事:US-001、US-002、US-008、US-009;验证默认 16 路站点的批量接入与运维收敛基础。
- 交互清单:IX-003、IX-004、IX-015、IX-016 的后端容量证据;不修改已确认 HTML 原型。
- 相关页面 / 路由:复用冻结的 7 个 `/api/v1` Control API endpoint、`/metrics`、MediaMTX v3 API;不增加公共接口。
## 方案
1. 新增 `Sense/scripts/t014-capacity.ps1` 一键基准:校验冻结的 PostgreSQL 17.10、MediaMTX v1.19.3、FFmpeg 与 Go,使用随机回环端口和系统临时目录启动隔离 PostgreSQL、源 MediaMTX、生产 MediaMTX 与 Sense。migration 只读取并重放现有 `001`~`011`,不修改或连接本机已有 `D:\pgsql17\data`/5432。
2. 先用 FFmpeg 生成 640×360、10 fps、H.264、无音频、无人物的短循环媒体夹具,再启动 16 个独立 FFmpeg `-c copy` RTSP publisher,每路有独立进程与 Path,可单独停止/恢复。预编码发布避免把 16 路软件编码开销计入 Sense/MediaMTX 基线;不把同一 publisher fan-out 冒充独立源。
3. 在临时 PostgreSQL 中建立固定实验室 Tenant/Site/Area,Site 配额精确为 16;运行时生成 Control API opaque token、SHA-256 注册表和 cursor key,文件只存在于临时目录。通过真实 HTTP API 创建 16 个 disabled 视频设备、批量启用并收敛后,验证第 17 个 enabled 同站点设备稳定返回 `quota_exceeded` 且不写入台账;disabled 台账项不冒充已占用通道。
4. 使用真实 `devices:batchDesiredState` 和每台设备最新 ETag 执行 16 路批量启用、批量停用、再次启用;每次要求 operation 的 16 个结果全部成功。分别验证生产 MediaMTX 配置/运行 Path 为 16、0、16,设备最终状态与 generation/observed generation 收敛,`unconverged=0`。
5. 在再次启用后同时停止固定四个 publisher,要求能观察到局部离线/未收敛且其余路径继续在线;恢复四个独立进程后自动回到 16 路在线和 `unconverged=0`。不得通过改数据库、人工编辑 MediaMTX 配置或重建进程集合掩盖失败。
6. 正式稳定观察默认 30 分钟、每 10 秒采样。记录 Sense 与生产 MediaMTX 的归一化 CPU、working set、private bytes、handle 峰值,PostgreSQL连接峰值,以及 MediaMTX aggregate inbound bytes/吞吐、在线 Path、Sense 低基数指标和未收敛数。功能正确性有硬门禁;机器相关资源值只记录,不设置伪通用阈值。
7. 脚本输出不含 token、DSN、端口清单、Path/source URI 或客户信息的 JSON 摘要;支持显式输出到仓库外路径。失败不输出成功结论,finally 停止全部叶子进程、停止临时 PostgreSQL并校验清理路径;可选调试短窗口必须标记 `formal_eligible=false`,不得作为正式验收。
8. 将正式运行的硬件/软件版本、命令、功能时间、观察样本、资源统计、失败记录、限制和结论写入 `docs/research/sense-16-stream-capacity.md`,同步 README、架构、路线图和当前状态。增加静态契约测试,锁定 16/17/128/129 语义、无秘密输出、临时目录防护、真实批量 API、独立 publisher 和正式窗口规则。
## 不可变约束
- 阈值 / 数值边界:正式源数精确为 16;Site 默认配额 16、允许范围 1~128,已有 16 路 enabled 时第 17 路 enabled 必须拒绝,129 仍非法;批量 API 上限保持 128。正式观察不少于 30 分钟、采样周期 10 秒;局部故障固定 4 路且不得影响其余 12 路。短窗口只能 smoke。
- 判定式 / 状态转换:16 个设备先 disabled 创建,随后 `16 enabled → 16 disabled → 16 enabled`;每轮批量结果必须全部成功。启用完成必须配置 Path=16、运行在线 Path=16、`unconverged=0`;停用完成必须配置 Path=0、设备 offline 且各自 observed generation 追平。四路故障恢复后必须再次满足 16 路在线和 `unconverged=0`。
- 安全边界:只绑定随机回环端口;运行时 token/cursor key/DSN/临时路径不进入 Git、普通日志或结果摘要。脚本不得读取 `ip_camera.env`,不得访问客户网络或现有 PostgreSQL data/service;递归清理前必须验证目标位于系统临时目录且名称含 T-014 随机前缀。
- 既有契约:不修改 Control OpenAPI v1、审计 v1/v2、MediaMTX 生成代码或数据库 migration;不把 16 写入业务数组/协议硬上限。T-001/T-006 的实机结论、T-007 真实五上游门禁与未来 T-013 WireGuard 均保持独立。
## 验收要点
- 任务相关验证:先以短窗口运行脚本 smoke,再执行默认不少于 30 分钟的正式 16 路基准;验证 16 个独立 publisher、第 17 路 enabled 配额拒绝、三轮批量状态、16/0/16 Path、四路故障隔离/恢复、最终 `unconverged=0` 和资源摘要。`python -m unittest discover -s tests -p "test_sense_capacity_contract.py"` 锁定脚本安全/语义。
- 完整门禁:`./init.ps1`、三条 Python 治理命令、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 和 `git diff --check` 全部通过;本任务不修改 Go 业务代码,但仍回归全部 Sense 包和 PostgreSQL v5。
- 人工 / 设备验收:不需要摄像头、GPU、客户网络或客户数据。任务所有者核对 30 分钟原始 JSON 摘要、进程独立性和报告;真实摄像头/网络验收仍由 T-007/T-013 完成。
- 构建产物:`Sense/scripts/t014-capacity.ps1`、`docs/research/sense-16-stream-capacity.md`、静态契约测试与同步文档;临时二进制、媒体、数据库、凭据和日志运行后删除,不提交。
## 边界(不改什么)
不实现或验证 WireGuard/T-013、客户网络、真实 16 摄像头、录像、下游 reader、AI 解码/推理、GPU、Bell UI/服务、Outbox relay、告警规则、跨 MediaMTX 分片或 64/128 路容量;不把本机资源样本转换为最低硬件规格或 SLA。本任务不解除 T-007,失败样本不得从报告中删除。
## 协作约束
- 责任 Agent:由 dispatcher 分配。
- 唯一写入者:同责任 Agent。
- 委派:默认不启用。
- Gitea:任务规格先进入默认分支,再创建唯一 Issue 并回填编号;领取时记录 `context_ref`、claim / 工作分支和全部允许写路径。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。T-007 虽为 waiting,但写路径包含 `Sense/`;T-014 活跃期间不得恢复 T-007。
## 执行记录
### 2026-08-10 正式 16 路基线
- 在提交 `d029067aa750ad75a2d79ef8e6c51b6bad55096d` 执行 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -OutputPath (Join-Path $env:TEMP 'yovision-t014-formal-final.json')`,成功输出 `formal_eligible=true`。固定环境为 Go 1.26.5、PostgreSQL 17.10、MediaMTX v1.19.3、FFmpeg 8.1.2;主机为 24 logical processors / 15.78 GiB。
- 16 个独立 publisher 在 `7.6 s` 就绪;三轮 16 项 batch 均 `succeeded`,请求分别为 `0.041 s / 0.015 s / 0.046 s`,收敛分别为 `3.1 s / 1.0 s / 3.1 s`,配置 Path 为 `16 → 0 → 16`。16 路 enabled 后第 17 路 enabled 返回 `quota_exceeded`。
- 固定停止第 5~8 路后 `1.0 s` 检测到精确 4 路受影响,其余 12 路在线;恢复 `5.1 s` 后回到 16 路在线。稳定窗口 `1800.1 s`、10 秒绝对节拍、180 个样本,最大/最终 `unconverged=0`,聚合入站 `15.443 Mbps`,帧错误 0。
- 资源记录:Sense CPU 总计 `5.594 s`、working set 峰值 `69.99 MiB`、private 峰值 `57.58 MiB`、handle 峰值 228;生产 MediaMTX 分别为 `26.812 s / 45.27 MiB / 79.23 MiB / 295`;PostgreSQL 连接峰值 3,数据库 `8.90 MiB`。详细边界与失败记录见 `docs/research/sense-16-stream-capacity.md`。
- 正式退出后复查 T-014 session、FFmpeg、MediaMTX 和 Sense 数量均为 0;结果不含 token、DSN、端口、设备 ID、Path/source URI 或客户信息。本机现有 5432 listener 未被停止或修改。
### 2026-08-10 完整门禁
- PowerShell parser 与 `python -m unittest discover -s tests -p "test_sense_capacity_contract.py" -v` 通过,T-014 静态契约 6/6 通过。
- `./init.ps1` 通过:58 个 Python 测试成功,MediaMTX/Control API 生成漂移检查、全部 Sense Go 包测试、`go vet` 与 `go build` 均成功。
- `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 通过:PostgreSQL 17.10 隔离集群重放 `001`~`011`,权限断言与 `yovision/sense/internal/store` 真实 PostgreSQL 测试成功,临时端口和 PGDATA 已清理。
- `python scripts/validate_agent_context.py`、`python -m unittest discover -s tests -p "test_*.py"`(58 项)、`python scripts/validate_harness_governance.py` 与 `git diff --check` 均通过。生成文件只有现有 LF→CRLF 提示,没有内容漂移或任务范围外修改。
### 2026-08-08~2026-08-10 基准工具失败与修正
- 正式结论没有拼接失败样本。依次修复并回归:MediaMTX ZIP/EXE 双指纹、disabled 不占配额、problem+json byte array 解码、`pg_ctl start` 管道句柄继承、Windows EXE 文件锁与进程句柄释放、固定 sleep 导致 30 分钟不足 180 样本、根目录 Go launcher 与 Sense 1.26.5 工具链口径不一致,以及首轮 batch 复用创建 ETag 的调和竞速。
- 中断或失败运行均未输出正式成功 JSON;已核验并停止临时 PostgreSQL,清除精确的单次 session。最终脚本按绝对 10 秒节拍取得 180 个样本,三轮 batch 提交前都读取最新 ETag,清理使用共享截止时间并释放异步输出/进程句柄。
### 2026-08-08 一分钟端到端 smoke
- `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -ObservationMinutes 1` 通过并输出 `formal_eligible=false`:16 个独立 publisher 全部在线,三轮 16 项 batch 均 `succeeded`,配置 Path 为 `16 → 0 → 16`,第 17 路 enabled 返回 `quota_exceeded` 且台账仍为 16 项。
- 首次启用、停用、再次启用分别在 `3.1 s`、`1.0 s`、`3.1 s` 收敛;同时停止四路后 `1.0 s` 观察到精确四路受影响、其余 12 路在线,恢复 `5.2 s`。稳定观察 `60.8 s`/6 次采样,最大与最终 `unconverged` 均为 0,最终在线 Path 16,aggregate inbound `15.432 Mbps`,帧错误 0。
- 保留三类脚本修正记录:发布 ZIP 与 EXE 需要不同 SHA-256;disabled 台账不消耗视频运行配额,故第 17 路门禁必须在 16 路 enabled 后验证;PowerShell 对 `application/problem+json` 返回 byte array,需显式 UTF-8 解码后校验稳定 `code`。每次失败均停止并清理临时 PostgreSQL/进程,未拼接成功片段。
### 2026-08-08 领取任务
- dispatcher `ila` 将 Issue #47 分配给 `codex`;`context_ref` 为 `8ebb8ced492c614106e766192c59b7f13fb4ad0f`,claim 为 `claims/T-014`,工作分支为 `agent/codex/T-014`。
- 已读回 Issue `status/doing`、assignee、dispatcher 发布的完整 CLAIM 与两个分支 SHA;接受 frontmatter 全部写路径。T-007 继续 waiting,本任务期间不恢复,当前无活跃写路径冲突。
### 2026-08-08 Gitea 映射
- 任务规格通过 PR #46 合入默认分支后创建唯一主 Issue #47;本次只回填双向映射,映射合入并读回前不领取任务。
### 2026-08-08 任务定义
- 项目负责人确认客户尚未提供网络环境,WireGuard T-013 后置;本任务先建立不依赖客户网络的本地 16 路软件基线。
- 选择 PostgreSQL v5 + 真实 Control API,而不是沿用 SQLite 直接播种,确保实际覆盖 M2 的认证、配额、ETag、批量 operation、调和与 MediaMTX 路径。
- 选择 16 个独立、预编码 `-c copy` 合成 publisher 作为正式可重复负载;T-006 已单独覆盖准入实机,本任务不混入公网摄像头波动,也不把合成结果表述为真实 16 机或生产 SLA。
+92
View File
@@ -0,0 +1,92 @@
---
id: T-015
title: 建立 Bell 事件 v0.1 校验与不可变 PostgreSQL 存储
phase: 3
deps: [T-014]
status: DONE
created: 2026-08-10
issue: 51
context_ref: 5b42f58f23255831067f44206849344909d74ee9
claim_branch: claims/T-015
work_branch: agent/codex/T-015
write_paths:
- docs/tasks/T-015.md
- Bell/
- deploy/postgres/012_bell_events.sql
- deploy/postgres/013_privileges_bell_events.sql
- deploy/postgres/tests/assertions.sql
- deploy/postgres/README.md
- scripts/test_postgres.ps1
- tests/test_bell_event_contract.py
- tests/test_postgres_contract.py
- docs/raw/contracts/README.md
- docs/00-ai-start-here.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/06-tasks.md
- docs/api.md
- docs/current-state.md
- AGENTS.md
- init.ps1
- init.sh
---
## 问题 / 背景
M3 要求 Bell 首次真实消费冻结的事件契约 v0.1,生成平台 ULID,并把事件事实保存为不可变记录。当前 Bell 只有目录占位,PostgreSQL 也只有 Site/Area 投影源表;如果先做规则或 Alert,会把未经校验、可被覆盖的事件数据变成后续状态机的错误基础。
冻结契约中还存在一处文档自相矛盾:`docs/raw/contracts/README.md` 的修订记录、雷达示例和 JSON Schema 均允许非成像事件的 `snapshot_uris=[]`,但 §5 的旧句仍把空数组列为 schema 可拒绝项。本任务先把该句校正为现有冻结事实,不修改 schema 或字段语义。
## 关联需求与交互(如适用)
- 用户故事:无直接 UI;为 US-003、US-004、US-006 的后续事件/处置流程提供数据基础。
- 交互清单:不适用;本任务不实现 Bell Web/H5 页面或公共查询 API。
- 相关页面 / 路由:不适用。
## 方案
1. 在 `Bell/` 建立独立 Go module,复用项目冻结的 Go 1.26.5、PostgreSQL 17.10 和 `pgx/v5 v5.10.0`;冻结 Draft 2020-12 校验器与 ULID 库版本、许可证和退出路线,不引入前端或消息总线。
2. 把冻结 schema 逐字节复制到 `Bell/contracts/event-v0.1.schema.json` 并以测试阻止漂移。Bell 内部 ingest factory 接收不含平台 `id` 的候选事实,拒绝上游自报 `id`,由 Bell 生成 `evt_` ULID 后再执行 schema 与代码级断言。Brain→Bell 的 HTTP/消息总线 transport 继续待定,本任务不借内部 Go 类型冻结公共网络协议。
3. 代码级校验覆盖时间顺序/延迟自洽、`confidence=null`、证据 URI 脱敏、唯一 primary 且与顶层设备一致,以及视频隐私准入。隐私与客户敏感名称通过必需 port 注入;解析/映射不可用时失败关闭,不使用 allow-all 生产默认值。
4. 新增 Bell event repository。`bell.events` 保存最终规范化 JSON、摘要和可查询的最小索引字段;平台 ID 冲突时仅允许“同 ID + 同摘要”的幂等重放,不同摘要稳定冲突。后续 outcome 写入独立的 append-only `bell.event_outcomes`,不得更新原事件事实。
5. migration 增加独立 NOLOGIN `bell_runtime` 最小权限角色。migration owner `bell_app` 持有对象;运行时只获得必需的 `SELECT/INSERT`,明确没有事件表的 `UPDATE/DELETE/TRUNCATE`,并以数据库 trigger 拒绝事实改写。生产/共享实例仍由管理员离线 migration,不由 Bell 高权限自迁移。
6. 扩展隔离 PostgreSQL 17.10 harness,使用 `bell_runtime` 登录运行真实 repository/权限/重放/不可变性集成测试;同步根启动入口、技术栈、架构、API 状态和当前仓库现实。
## 不可变约束
- 阈值 / 数值边界:请求体/单事件规范化 JSON 上限 1 MiB;延迟自洽误差严格小于 100 ms;事件列表/公共 API 不在本任务内;任何默认 16 路值不成为事件数量上限。
- 判定式 / 状态转换:Bell 是 `evt_` ULID 唯一生成者;候选事实不得携带 `id`;最终事实必须同时通过 Draft 2020-12 schema 和 README 六项代码级断言。相同平台 ID/相同摘要为幂等成功,相同 ID/不同摘要为冲突;原事件永不 UPDATE/DELETE,后续 outcome 只能追加。
- 安全边界:未知顶层字段拒绝;视频隐私映射或敏感名称策略不可用时失败关闭;普通错误和测试证据不回显事件全文、证据 URI、客户名、凭据或内部堆栈;`bell_runtime` 不拥有 migration 对象且没有事件事实改写权限。
- 既有契约:`docs/raw/contracts/event-v0.1.schema.json` 字段、枚举与 v0.1 语义不变;只修正文档中与 schema/示例冲突的旧句。Brain transport、规则、Alert、证据回捞、RBAC/JWT/OIDC 和事件公共 API 均另立任务。
## 验收要点
- 任务相关验证:`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`;`python -m unittest discover -s tests -p "test_bell_event_contract.py" -v`;验证三个冻结示例、负向 schema/六断言、ULID 所有权、幂等冲突和 append-only outcome。
- 完整门禁:因命中 Bell 脚手架、PostgreSQL migration、权限、根启动入口和冻结契约消费者,运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17`、`./init.ps1`、三条 Python 治理命令和 `git diff --check`。隔离测试必须证明现有 5432 listener 前后不变并自动清理临时集群。
- 人工 / 设备验收:不适用;本任务不依赖摄像头、客户网络、GPU、MinIO 或产品 UI。生产上线仍受法务/客户留存政策和 M3 现场 dry-run 门禁约束。
- 构建产物:`go -C Bell build ./...` 可构建全部 Bell 包;不生成可部署 Bell API 二进制,因为 Brain transport、认证和公共 API 尚未冻结。
## 边界(不改什么)
- 不实现 Brain mapper/投递、HTTP 或消息总线事件入口、规则引擎、Alert、ack、升级链、对象存储、证据切片、反馈回流、Bell Web/H5 或 JWT/OIDC。
- 不修改冻结事件 schema、示例字段或 Sense 业务代码;不把整数事件契约 ID 与现有文本逻辑 ID 的待定映射悄悄固化为跨系统规则。
- 不把 `bell_app` owner 凭据交给运行进程,不在仓库保存 DSN、密钥、客户数据、真实事件或真实证据 URI。
## 协作约束
- 责任 Agent:codex
- 唯一写入者:codex
- 委派:不启用。
- Gitea:Issue、`context_ref`、claim 与工作分支在双向映射及 dispatcher 分配后回填。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
## 执行记录
- 2026-08-10:按 M3 建议拆出本任务;完成仓库/远端状态检查和 `./init.ps1` 基线,现有 58 个 Python 测试及 Sense generate/test/vet/build 全部通过。实现尚未开始。
- 2026-08-10:冻结 Bell Go 1.26.5、`jsonschema/v6 v6.0.2`、`ulid/v2 v2.1.2`,复用 PostgreSQL 17.10 / pgx v5.10.0;建立 candidate→Bell ULID→最终 v0.1 schema/语义校验。三个冻结示例均通过,负向测试覆盖上游自报 ID、未知字段、时间/延迟、confidence、primary、证据脱敏、隐私拒绝/不可用和 1 MiB 上限。
- 2026-08-10:新增 `bell.events` 与 `bell.event_outcomes`、不可变 trigger 和独立 NOLOGIN `bell_runtime`。repository 验证同 ID/同摘要幂等、同 ID/不同摘要冲突、outcome 仅追加,以及运行登录无 `UPDATE/DELETE/TRUNCATE`。校正事件 README 中与 schema/雷达示例冲突的“空 snapshot 数组会被拒绝”旧句,未修改冻结 schema。
- 2026-08-10:`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...` 通过;`python -m unittest discover -s tests -p "test_bell_event_contract.py" -v` 5/5 通过,`test_postgres_contract.py` 8/8 通过。
- 2026-08-10:`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 通过;PostgreSQL 17.10 临时集群将 `001`~`013` migration 重放两次,Sense/Bell 真实 repository 与权限断言通过,随机端口、临时目录均清理,现有 5432 listener 未改变。
- 2026-08-10:更新根 `init.ps1`/`init.sh` 后运行 `./init.ps1` 通过:64 个 Python 测试、Sense generate/test/vet/build 和 Bell test/vet/build 全部成功。人工/设备/UI 验收不适用。
- 边界:本任务没有可部署 Bell API;Brain transport、认证、整数事件 ID 与文本逻辑 ID 映射及生产隐私 resolver 仍待后续任务,内部 Go factory 不能被表述为已上线跨系统入口。
+100
View File
@@ -0,0 +1,100 @@
---
id: T-016
title: 冻结并实现 Sense Outbox 到 Bell 全局审计幂等 relay
phase: 3
deps: [T-015]
status: DONE
created: 2026-08-10
issue: 55
context_ref: aca22f4667f0da77a102eb7ba15a93c0128df656
claim_branch: claims/T-016
work_branch: agent/codex/T-016
write_paths:
- docs/tasks/T-016.md
- docs/contracts/README.md
- docs/contracts/sense-audit-relay-v1.openapi.json
- Sense/cmd/sense-api/main.go
- Sense/internal/config/config.go
- Sense/internal/config/config_test.go
- Sense/internal/auditrelay/
- Sense/internal/store/audit_relay_postgres.go
- Sense/internal/store/audit_relay_postgres_test.go
- Sense/README.md
- Bell/cmd/bell-api/
- Bell/internal/audit/
- Bell/internal/store/audit_postgres.go
- Bell/internal/store/audit_postgres_test.go
- Bell/README.md
- deploy/postgres/014_audit_relay.sql
- deploy/postgres/015_privileges_audit_relay.sql
- deploy/postgres/tests/assertions.sql
- deploy/postgres/README.md
- scripts/test_postgres.ps1
- tests/test_sense_audit_relay_contract.py
- tests/test_postgres_contract.py
- docs/00-ai-start-here.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/06-tasks.md
- docs/api.md
- docs/current-state.md
---
## 问题 / 背景
T-010 已保证 Sense 设备创建、配置和期望态变更与脱敏 `sense.device_operation_outbox` 同事务,但 Outbox 仍不会离开 Sense;Bell 因而没有完整的跨系统全局审计事实。直接让 Sense 写 `bell` schema 会破坏三系统所有权,简单“POST 后标记成功”又无法处理崩溃重试、重复投递、多实例竞争、消息篡改或重放攻击。
T-015 已提供 Bell 的独立运行角色和不可变事实模式。本任务只冻结并实现设备操作审计 relay,不顺带定义 Brain 事件 transport、业务规则、Alert 或公共管理 API。
## 关联需求与交互(如适用)
- 用户故事:为 US-009、US-010 的全局审计追溯提供后端事实基础。
- 交互清单:IX-020 的审计归属后端基础;本任务不实现 `/audit` UI、筛选或深链页面。
- 相关页面 / 路由:公共 UI 不适用;新增仅内部使用的 `/internal/v1/audit-events:batch`。
## 方案
1. 冻结 `sense-audit-relay-v1.openapi.json`:Sense 通过 HTTP POST 批量发送 1~100 个 v1/v2 设备审计事实,Bell 返回与输入逐项对应的 `accepted | duplicate | rejected`;完整批次响应可按签名 nonce 幂等重放。
2. 请求使用 key ID、Unix 秒时间戳、随机 nonce 与 HMAC-SHA256。canonical string 固定为 `METHOD + path + timestamp + nonce + SHA256(body)` 的换行拼接;允许时钟偏差 5 分钟,nonce 收据保留 10 分钟。相同 key/nonce/请求摘要返回原响应,不同摘要返回 `409 replay_conflict`。密钥只从仓库外绝对路径文件读取,至少 32 个随机字节,不进数据库、日志或 Issue。
3. 非回环 transport 必须使用 HTTPS;回环 HTTP 只用于同机私有部署和测试,不提供跳过远端 TLS 的 flag。单次请求体最大 1 MiB、超时 10 秒,Bell 不回显原始 body、签名或敏感字段。
4. PostgreSQL v6 为 Sense Outbox 增加数据库时钟 lease、fencing token、last error 和 dead-letter 状态。最多 100 行用 `FOR UPDATE SKIP LOCKED` 领取;过期 worker 不能确认。`accepted/duplicate` 标记 delivered,逐项永久拒绝进入 dead letter,网络/5xx/整批认证失败按指数退避重试且不删除事实。
5. Bell 新增不可变 `bell.audit_events` 和可过期 `bell.audit_relay_receipts`。全局审计用 `(source_system,event_id)` 唯一;相同摘要为 duplicate,不同摘要逐项 `id_conflict`。审计事实没有 UPDATE/DELETE/TRUNCATE 权限并复用不可变 trigger;收据允许 Bell runtime 在限定表内维护过期记录。
6. 建立最小 `cmd/bell-api`,默认仅回环监听,暴露 health/ready 与内部 relay endpoint;数据库、监听地址、TLS 和 key 文件均来自私有环境。Sense relay 默认关闭,仅在 PostgreSQL v6、合法 Bell URL 和外部 key 文件齐备时启动后台 worker。
7. 单元测试覆盖签名向量、时间窗/nonce、body 限制、逐项确认、重复/冲突、配置失败关闭、退避和崩溃重领;隔离 PostgreSQL 17.10 测试覆盖 migration 重放、两端 repository、fencing、权限和安全清理。
## 不可变约束
- 阈值 / 数值边界:每批 1~100 项;body ≤1 MiB;HTTP deadline 10 秒;lease 30 秒;签名时钟偏差 ≤300 秒;nonce 收据 TTL 600 秒;退避从 1 秒指数增长并封顶 300 秒。16 不是审计批量或队列上限。
- 判定式 / 状态转换:只有 `accepted`/`duplicate` 可设置 `delivered_at`;`rejected` 必须带稳定错误码并 dead-letter;批次级网络/5xx/认证/响应缺项不得误标成功。所有完成/失败写入必须匹配 worker + fencing token + 未过期数据库 lease。
- 安全边界:非回环只允许 HTTPS;HMAC 使用至少 32 字节外部 secret 和 constant-time compare;时间戳、nonce、key ID、签名格式失败统一安全拒绝。Sense 不获得 Bell schema 权限,Bell 不读取 Sense Outbox;日志、响应、测试和指标不包含 secret、Authorization、原始 DSN、完整 payload 或租户/设备标签。
- 既有契约:本地事实继续符合 `sense-device-audit-v1/v2`,不原地修改两份 schema;relay v1 只增加传输 envelope/确认语义。Bell 全局审计是追加事实,不能改写 Sense 原事件或设备状态。
## 验收要点
- 任务相关验证:`go -C Sense test ./internal/auditrelay ./internal/store ./internal/config ./cmd/sense-api`、`go -C Bell test ./internal/audit ./internal/store ./cmd/bell-api`;`python -m unittest discover -s tests -p "test_sense_audit_relay_contract.py" -v`;OpenAPI JSON 可解析且签名/状态/阈值与实现一致。
- 完整门禁:因命中 Sense/Bell Go、PostgreSQL schema/权限和内部 HTTP 安全边界,运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17`、`./init.ps1`、三条 Python 治理命令及 `git diff --check`。临时 PostgreSQL 必须重放 `001`~`015` 两次,现有 5432 listener 不变。
- 人工 / 设备验收:不适用;回环 `httptest` 和隔离 PostgreSQL 足以验收协议与持久化。跨主机证书、客户网络、防火墙和密钥轮换演练留给部署任务,不能据此宣称生产网络已验收。
- 构建产物:`go -C Sense build ./...` 与 `go -C Bell build ./...`;Bell 最小 receiver 可由 `go -C Bell build ./cmd/bell-api` 构建,未配置 DSN/key 时必须拒绝启动。
## 边界(不改什么)
- 不实现 Brain→Bell 事件 transport、规则引擎、Alert/ack/升级、Bell 公共审计查询 API、Web/H5、JWT/OIDC、消息总线或客户网络部署。
- 不删除本地 delivered/dead-letter Outbox 行,不擅自冻结法务审计留存期;本版本只自动清理 10 分钟后 relay nonce 收据,全局审计事实不自动删除。
- 不提交 key 文件、DSN、客户标识、真实 payload 或私有实例配置;不为测试修改现有 PostgreSQL data 目录或 5432 服务。
## 协作约束
- 责任 Agent:codex
- 唯一写入者:codex
- 委派:不启用。
- Gitea:Issue、`context_ref`、claim 与工作分支在双向映射及 dispatcher 分配后回填。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
## 执行记录
- 2026-08-10:在 T-015 合并并关闭后拆出本任务;实现尚未开始。
- 2026-08-11:冻结 `sense-audit-relay-v1.openapi.json`,实现 Sense 外部 key/HMAC client、30 秒数据库 lease/fencing worker、逐项结果/dead letter/1~300 秒退避,以及 PostgreSQL v6 Outbox relay repository;默认关闭且远端 URL 强制 HTTPS。
- 2026-08-11:实现 Bell 最小 `cmd/bell-api`、300 秒时间窗与 constant-time HMAC 校验、600 秒 nonce 幂等收据、逐项 v1/v2 校验,以及 PostgreSQL v4 不可变 `bell.audit_events`;Sense/Bell 登录权限保持单向隔离,只有 Bell 可清理过期收据。
- 2026-08-11:`./init.ps1`、三条 Python 治理命令、Sense/Bell `test/vet/build` 与 `git diff --check` 全部通过;Python 共 68 项测试通过。
- 2026-08-11:`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 通过;PostgreSQL 17.10 临时集群将 `001`~`015` 重放两次,Sense fencing、Bell receipt/重复/冲突/不可变性和双方权限断言通过,随机端口与临时目录已清理,现有 5432 listener 未改变。
+77
View File
@@ -0,0 +1,77 @@
---
id: T-017
title: 建立 Brain 单路匿名区域事件可视化工程原型
phase: 3
deps: [T-016]
status: TODO
created: 2026-08-11
issue: 59
context_ref: null
claim_branch: null
work_branch: null
write_paths:
- docs/tasks/T-017.md
- Brain/
- docs/design/brain/
- docs/00-ai-start-here.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/06-tasks.md
- docs/07-user-stories.md
- docs/08-interaction-checklist.md
- docs/routes.md
- docs/current-state.md
- init.ps1
- init.sh
---
## 问题 / 背景
Sense 已能稳定提供 MediaMTX 路径,Bell 已有不可变事件存储,但 `Brain/` 仍是目录占位,客户无法看到“画面 → 匿名检测 → 区域判定 → 事件候选”的直接效果。直接把 `silver_pose` 合并进来会破坏独立交付边界;未经授权引入 Ultralytics 生产依赖又有商用许可风险。首步需要一个可重复、可替换检测器、不会伪装算法效果的单路工程原型,为后续 Brain→Bell ingress 提供真实生产者。
## 关联需求与交互(如适用)
- 用户故事:新增 US-014;同时关联 US-005 的区域规则配置目标。
- 交互清单:新增 IX-024;几何编辑遵守 IX-017 的绘制、撤销、清空、键盘等效和待校准语义。
- 相关页面 / 路由:`/brain-demo` 仅为回环工程演示入口,不属于 Bell 公共业务路由;原型为 `docs/design/brain/index.html`。
## 方案
1. 冻结 T-017 工程原型栈:本机已验证的 Python 3.10.11、NumPy 1.26.4、OpenCV 4.9.0.80。OpenCV HOG 人员检测只作为可替换 `Detector` port 的演示适配器;不冻结 Savant/DeepStream、生产模型或 GPU 容量。
2. 建立单路 frame source、detector、轻量 track、归一化多边形 zone、进入/停留判定和内存事件候选流水线。默认合成回放携带明确的 fixture 标识,保证无摄像头/模型也能重复验收;真实流模式只从仓库外绝对路径文件读取 URL,并优先指向 Sense 管理的 MediaMTX path。
3. 提供仅监听回环地址的本地演示服务:画面、检测框、track、区域、命中状态、推理延迟和最近事件可观察;区域支持鼠标绘制、撤销、清空、保存,以及无需精确点击的归一化坐标表单。页面使用自包含 HTML/CSS/JS,不加载 CDN、字体或第三方脚本。
4. 本地演示 API 不是跨系统契约,不接受平台事件 `id`,不写数据库、不发送 Alert、不管理录像。T-018 再冻结 Brain→Bell transport、逻辑身份、Bell ULID、持久重试与幂等确认。
5. 单元测试覆盖几何边界、进入/停留只触发一次、track 过期、事件候选不含平台 ID、URL 文件校验、回环绑定、API 输入限制和敏感信息不回显;HTML 契约测试覆盖响应式、键盘、焦点、44px 操作目标、reduced-motion、状态文本和无外链资源。
## 不可变约束
- 阈值 / 数值边界:单路原型默认处理 2 FPS;区域点使用 `[0,1]` 归一化坐标,3~32 个顶点;最近事件仅保留 100 项内存环;HTTP body 不超过 64 KiB。任何值不得成为 16/128 路或生产 worker 的硬上限。
- 判定式 / 状态转换:仅 track 中心点从区域外进入区域内时产生一次 `zone_entry` 候选;持续位于区域内不重复产生;离开后再次进入才可重新产生。fixture 与真实检测结果必须显式区分,HOG 无检测不得伪造框。
- 安全边界:服务只允许回环监听;真实流 URL 只从仓库外绝对路径、单行、受限大小文件读取,响应、日志、页面和异常不得回显 URL/凭据。禁止提交真实视频、截图、模型权重、凭据或客户标识。
- 既有契约:Brain 只产生 `source_event_id` 和候选事实,平台 `evt_` ULID 仍由 Bell 生成;本任务不修改冻结 event v0.1,不把本地 demo API 宣称为 Brain→Bell transport。`silver_pose` 保持独立仓库且不进入依赖/构建上下文。
## 验收要点
- 任务相关验证:`python -m unittest discover -s Brain/tests -p "test_*.py" -v`、`python -m compileall -q Brain`;启动合成模式后用回环 HTTP smoke 验证状态、画面、区域保存和事件候选;HTML 静态契约测试通过。
- 完整门禁:因首次建立 Brain 脚手架并更新标准入口,运行 `./init.ps1`、三条 Python 治理命令以及 `git diff --check`;Sense/Bell 既有 test/vet/build 仍须由标准入口通过。
- 人工 / 设备验收:真实摄像头和目标 GPU 不作为本任务 DONE 门禁;合成回放验证工程闭环。真实流只做可选本机 smoke,结果不能外推为算法效果、GPU 容量或生产 SLA。UI 产品确认留给 Bell 业务闭环任务,工程演示页必须明确“非生产模型验收”。
- 构建产物:源码运行原型,无独立安装包;入口为 `python -m Brain.yovision_brain --source synthetic`,依赖清单和启动说明写入 `Brain/README.md`。
## 边界(不改什么)
- 不实现 Brain→Bell 网络投递、持久 Outbox、Bell ingress、规则/Alert/ack、证据切片、对象存储、反馈回流或公共认证。
- 不承诺检测召回率/误报率,不购买或引入 Ultralytics 生产许可,不冻结 Savant/DeepStream、CUDA、跟踪/ReID 或每 worker 路数。
- 不修改 Sense、Bell、`_reference/` 或 `D:\OPC\silver_pose`;不直接连接摄像头管理端或绕过 MediaMTX 系统边界。
## 协作约束
- 责任 Agent:codex
- 唯一写入者:codex
- 委派:不启用。
- Gitea:Issue、`context_ref`、claim 与工作分支在双向映射及 dispatcher 分配后回填。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
## 执行记录
- 2026-08-11:按用户指定顺序建立 T-017;实现尚未开始。主分支 `./init.ps1` 基线通过(68 项根测试,Sense/Bell test/vet/build 通过)。
+3 -3
View File
@@ -10,9 +10,9 @@
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
Set-Location -Path $PSScriptRoot Set-Location -Path $PSScriptRoot
# Sense 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。 # Sense/Bell 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
$InstallCmd = "go -C Sense mod download" $InstallCmd = "go -C Sense mod download; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell mod download"
$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }" $VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx ./internal/controlapi; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }"
$StartCmd = "go -C Sense run ./cmd/sense-api" $StartCmd = "go -C Sense run ./cmd/sense-api"
function Assert-Configured { function Assert-Configured {
+3 -3
View File
@@ -12,9 +12,9 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
# Sense 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。 # Sense/Bell 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
INSTALL_CMD=(go -C Sense mod download) INSTALL_CMD=(bash -lc "go -C Sense mod download && go -C Bell mod download")
VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && go -C Sense generate ./internal/mtx && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./...") VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && go -C Sense generate ./internal/mtx ./internal/controlapi && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./... && go -C Bell test ./... && go -C Bell vet ./... && go -C Bell build ./...")
START_CMD=(go -C Sense run ./cmd/sense-api) START_CMD=(go -C Sense run ./cmd/sense-api)
ensure_configured() { ensure_configured() {

Some files were not shown because too many files have changed in this diff Show More