From b98592b29bc887150252a7989f365dc6c23a8084 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Sun, 19 Apr 2026 10:21:16 +0800 Subject: [PATCH] Add config status metadata reporting --- agent/internal/httpapi/config_status_test.go | 101 ++++++++++++ agent/internal/httpapi/extras.go | 98 +++++++++++ agent/internal/httpapi/server.go | 1 + ...9-config-management-minimal-loop-design.md | 154 ++++++++++++++++++ tests/test_render_config.py | 48 ++++++ tools/render_config.py | 41 ++++- 6 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 agent/internal/httpapi/config_status_test.go create mode 100644 docs/superpowers/specs/2026-04-19-config-management-minimal-loop-design.md diff --git a/agent/internal/httpapi/config_status_test.go b/agent/internal/httpapi/config_status_test.go new file mode 100644 index 0000000..e8ee825 --- /dev/null +++ b/agent/internal/httpapi/config_status_test.go @@ -0,0 +1,101 @@ +package httpapi + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "rk3588sys/agent/internal/config" + "rk3588sys/agent/internal/procctl" +) + +type fakeProcessController struct { + status procctl.Status +} + +func (f fakeProcessController) Enabled() bool { return true } +func (f fakeProcessController) Status() (procctl.Status, error) { + return f.status, nil +} +func (f fakeProcessController) Version() (string, error) { return "", procctl.ErrNotSupported } +func (f fakeProcessController) Start(string) (procctl.Status, error) { + return procctl.Status{}, procctl.ErrNotSupported +} +func (f fakeProcessController) Stop() (procctl.Status, error) { + return procctl.Status{}, procctl.ErrNotSupported +} +func (f fakeProcessController) Restart(string) (procctl.Status, error) { + return procctl.Status{}, procctl.ErrNotSupported +} +func (f fakeProcessController) BinaryInfo() (procctl.BinaryUpdateResult, error) { + return procctl.BinaryUpdateResult{}, procctl.ErrNotSupported +} +func (f fakeProcessController) UpdateBinary(io.Reader, int64, string) (procctl.BinaryUpdateResult, error) { + return procctl.BinaryUpdateResult{}, procctl.ErrNotSupported +} +func (f fakeProcessController) RollbackBinary(string) (procctl.BinaryUpdateResult, error) { + return procctl.BinaryUpdateResult{}, procctl.ErrNotSupported +} + +func TestHandleConfigStatusReportsMetadataHashAndMediaStatus(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + body := []byte(`{"metadata":{"config_id":"cfg-1","config_version":"v1"},"instances":[]}`) + if err := os.WriteFile(cfgPath, body, 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + if err := os.WriteFile(cfgPath+".last_good.json", []byte(`{"metadata":{"config_id":"cfg-0"}}`), 0o644); err != nil { + t.Fatalf("write last good: %v", err) + } + + s := &Server{ + agentCfg: config.AgentConfig{ConfigPath: cfgPath}, + proc: fakeProcessController{status: procctl.Status{ + Running: true, + Pid: 1234, + ConfigPath: cfgPath, + StartedAtMS: 1000, + }}, + } + + req := httptest.NewRequest(http.MethodGet, "/v1/config/status", nil) + rr := httptest.NewRecorder() + s.handleConfigStatus(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status code: got %d body=%s", rr.Code, rr.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got["exists"] != true { + t.Fatalf("exists = %v", got["exists"]) + } + metadata, ok := got["metadata"].(map[string]any) + if !ok { + t.Fatalf("metadata missing or wrong type: %#v", got["metadata"]) + } + if metadata["config_id"] != "cfg-1" || metadata["config_version"] != "v1" { + t.Fatalf("metadata = %#v", metadata) + } + sum := sha256.Sum256(body) + wantSHA := hex.EncodeToString(sum[:]) + if got["sha256"] != wantSHA { + t.Fatalf("sha256 = %v want %s", got["sha256"], wantSHA) + } + media, ok := got["media_server"].(map[string]any) + if !ok || media["running"] != true || media["pid"] != float64(1234) { + t.Fatalf("media_server = %#v", got["media_server"]) + } + lastGood, ok := got["last_good"].(map[string]any) + if !ok || lastGood["exists"] != true { + t.Fatalf("last_good = %#v", got["last_good"]) + } +} diff --git a/agent/internal/httpapi/extras.go b/agent/internal/httpapi/extras.go index 73f101b..0051f9d 100644 --- a/agent/internal/httpapi/extras.go +++ b/agent/internal/httpapi/extras.go @@ -3,6 +3,7 @@ package httpapi import ( "crypto/sha256" "encoding/hex" + "encoding/json" "net" "net/http" "os" @@ -16,6 +17,16 @@ import ( "rk3588sys/agent/internal/sysinfo" ) +type configFileStatus struct { + Exists bool `json:"exists"` + Path string `json:"path"` + Sha256 string `json:"sha256,omitempty"` + Size int64 `json:"size,omitempty"` + MtimeMS int64 `json:"mtime_ms,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Error string `json:"error,omitempty"` +} + func defaultAuditPath(baseDir string) string { if strings.TrimSpace(baseDir) == "" { return filepath.Join("logs", "agent_audit.jsonl") @@ -165,6 +176,93 @@ func (s *Server) handleVersions(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +func (s *Server) handleConfigStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if !s.authorize(r, false) { + errorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + + current := readConfigFileStatus(s.agentCfg.ConfigPath) + lastGoodPath := s.agentCfg.ConfigPath + ".last_good.json" + lastGood := readConfigFileStatus(lastGoodPath) + + resp := map[string]any{ + "ok": true, + "config_path": filepath.ToSlash(s.agentCfg.ConfigPath), + "exists": current.Exists, + "last_good_path": filepath.ToSlash(lastGoodPath), + "last_good": lastGood, + } + if current.Exists { + resp["size"] = current.Size + resp["mtime_ms"] = current.MtimeMS + resp["sha256"] = current.Sha256 + } + if len(current.Metadata) > 0 { + resp["metadata"] = current.Metadata + } + if current.Error != "" { + resp["error"] = current.Error + } + + media := map[string]any{"supported": s.proc != nil && s.proc.Enabled()} + if s.proc != nil && s.proc.Enabled() { + if st, err := s.proc.Status(); err == nil { + media["running"] = st.Running + media["pid"] = st.Pid + media["config_path"] = filepath.ToSlash(st.ConfigPath) + media["started_at_ms"] = st.StartedAtMS + } else { + media["error"] = err.Error() + } + } + resp["media_server"] = media + + writeJSON(w, http.StatusOK, resp) +} + +func readConfigFileStatus(path string) configFileStatus { + out := configFileStatus{Path: filepath.ToSlash(path)} + if strings.TrimSpace(path) == "" { + out.Error = "path is empty" + return out + } + + info, err := assets.Info(path) + if err != nil { + if os.IsNotExist(err) { + return out + } + out.Error = err.Error() + return out + } + out.Exists = true + out.Sha256 = info.Sha256 + out.Size = info.Size + out.MtimeMS = info.MtimeMS + + b, err := os.ReadFile(path) + if err != nil { + out.Error = err.Error() + return out + } + var root struct { + Metadata map[string]any `json:"metadata"` + } + if err := json.Unmarshal(b, &root); err != nil { + out.Error = "config is not valid json: " + err.Error() + return out + } + if len(root.Metadata) > 0 { + out.Metadata = root.Metadata + } + return out +} + func (s *Server) handleAssets(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") diff --git a/agent/internal/httpapi/server.go b/agent/internal/httpapi/server.go index 078b41f..6ff0e17 100644 --- a/agent/internal/httpapi/server.go +++ b/agent/internal/httpapi/server.go @@ -108,6 +108,7 @@ func New(agentCfg config.AgentConfig, baseDir string, ms *mediaserver.Client, st mux := http.NewServeMux() mux.HandleFunc("/v1/info", s.handleInfo) + mux.HandleFunc("/v1/config/status", s.handleConfigStatus) mux.HandleFunc("/v1/config", s.handleConfig) mux.HandleFunc("/v1/config/ui/schema", s.handleConfigUISchema) mux.HandleFunc("/v1/config/ui/state", s.handleConfigUIState) diff --git a/docs/superpowers/specs/2026-04-19-config-management-minimal-loop-design.md b/docs/superpowers/specs/2026-04-19-config-management-minimal-loop-design.md new file mode 100644 index 0000000..77292a1 --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-config-management-minimal-loop-design.md @@ -0,0 +1,154 @@ +# Config Management Minimal Loop Design + +## Purpose + +Improve configuration management without returning to hand-maintained full JSON configs. The first implementation loop makes rendered configs traceable and lets the RK3588 agent report which config file is present and, when metadata is available, which config identity it represents. + +This is intentionally smaller than the full backend management system. It creates the device-side facts that the backend can rely on later. + +## Current Context + +The maintained media-server config source is the template/profile/overlay workflow: + +- Template: `configs/templates/workshop_face_shoe_alarm.json` +- Device/site profile: `configs/profiles/local_3588_test.json` +- Scenario overlays: `configs/overlays/*.json` +- Generated runtime configs: `configs/generated/*.json` + +The agent already exposes build metadata through UDP discovery, `GET /v1/info`, and `GET /v1/versions`. It also has older `config/ui/*` endpoints that generate configs from Go-embedded templates. Those endpoints remain supported, but this work should not expand the embedded-template model. + +## First-Loop Scope + +1. Add optional trace metadata to rendered configs. +2. Add an agent read API that reports config path, hash, timestamps, metadata, and media-server process config path when available. +3. Keep current `GET /v1/config` and `PUT /v1/config` behavior intact. +4. Keep generated config files disposable and out of git. + +Out of scope for this loop: + +- Full backend UI implementation. +- Full profile/overlay package upload and server-side rendering in Go. +- Hot reload guarantees for hardware-dependent media-server behavior. +- Replacing existing `config/ui/*` endpoints. + +## Config Metadata Model + +Rendered configs may include a top-level `metadata` object: + +```json +{ + "metadata": { + "config_id": "local_3588_face_debug", + "config_version": "20260419.001", + "template": "workshop_face_shoe_alarm", + "template_path": "configs/templates/workshop_face_shoe_alarm.json", + "profile": "local_3588_test", + "profile_path": "configs/profiles/local_3588_test.json", + "overlays": [ + "face_debug" + ], + "overlay_paths": [ + "configs/overlays/face_debug.json" + ], + "rendered_by": "tools/render_config.py", + "rendered_at": "2026-04-19T00:00:00Z" + } +} +``` + +`metadata` is operational trace data. Media-server should ignore it if it only consumes `global`, `queue`, `templates`, `instances`, or `graphs`. + +`config_hash` should not be embedded into the JSON in this loop because hashing a file that contains its own hash creates awkward circular semantics. The agent computes and reports the file hash instead. + +## Render Tool Behavior + +`tools/render_config.py` gains optional flags: + +- `--config-id` +- `--config-version` +- `--rendered-at` +- `--metadata-json` + +When any metadata flag is provided, the script writes top-level `metadata`. Without those flags, existing output remains compatible except for stable implementation details needed by tests. + +`--metadata-json` accepts a JSON object and is merged first. CLI fields override same-named metadata keys so automation can supply defaults and still stamp a specific config ID/version. + +## Agent API + +Add: + +`GET /v1/config/status` + +Response shape: + +```json +{ + "ok": true, + "config_path": "/etc/rk3588sys/config.json", + "exists": true, + "size": 12345, + "mtime_ms": 1776528000000, + "sha256": "hex", + "metadata": { + "config_id": "local_3588_face_debug", + "config_version": "20260419.001" + }, + "last_good_path": "/etc/rk3588sys/config.json.last_good.json", + "last_good": { + "exists": true, + "size": 12340, + "mtime_ms": 1776520000000, + "sha256": "hex", + "metadata": {} + }, + "media_server": { + "supported": true, + "running": true, + "pid": 1234, + "config_path": "/etc/rk3588sys/config.json", + "started_at_ms": 1776528005000 + } +} +``` + +If the config file is missing, the endpoint returns `200` with `exists:false` and no hash instead of `404`. This makes the backend device-detail page simpler: missing config is status, not transport failure. + +## Backend-Visible Field Boundaries + +User-facing configurable fields should be profile/overlay parameters such as: + +- RTSP/video source +- device/site/channel identity +- backend token/API URLs +- MinIO endpoint, bucket, and credentials +- face gallery path when deployment layout requires it +- selected overlays for debug, sensitivity, or production quiet mode +- carefully selected alarm thresholds and cooldowns + +Internal fields stay hidden unless an engineering mode later exposes them: + +- DAG edges and internal node IDs +- shared state keys +- gallery embedding dimension +- model input/output tensor assumptions +- CPU affinity and low-level queue tuning +- tracker and plugin glue parameters that can break cross-node contracts + +## Validation + +Local code-level validation: + +- Python unit tests or focused script checks for metadata rendering. +- Go tests for `GET /v1/config/status` response behavior. +- `git diff --check`. +- `go test ./...` in `agent` if changes touch agent code. + +RK3588 device-side validation: + +- Pull latest code on device. +- Render a config with metadata. +- Start or restart media-server with that generated config. +- Query `GET /v1/config/status`, `GET /v1/info`, and `GET /v1/versions`. +- Confirm `/tmp/media-server.log` shows the expected runtime config path and no startup regression. + +Device-side validation is required before claiming hardware/runtime behavior is verified. diff --git a/tests/test_render_config.py b/tests/test_render_config.py index af5f170..ed68505 100644 --- a/tests/test_render_config.py +++ b/tests/test_render_config.py @@ -140,6 +140,54 @@ class RenderConfigTest(unittest.TestCase): self.assertEqual(alarm["face_debug"]["unknown_candidate_interval_ms"], 0) self.assertEqual(alarm["rules"][0]["cooldown_ms"], 15000) + def test_render_adds_trace_metadata_when_requested(self): + module = load_module() + with tempfile.TemporaryDirectory() as tmp_dir: + tmp = pathlib.Path(tmp_dir) + template_path = tmp / "template.json" + profile_path = tmp / "profile.json" + + template_path.write_text( + """ + { + "name": "pipeline", + "template": { + "nodes": [{"id": "in", "type": "input_rtsp"}], + "edges": [] + } + } + """, + encoding="utf-8", + ) + profile_path.write_text( + """ + { + "name": "cam1", + "params": {"rtsp_url": "rtsp://example/cam1"} + } + """, + encoding="utf-8", + ) + + rendered = module.render( + template_path, + profile_path, + [], + metadata={ + "config_id": "local_3588_face_debug", + "config_version": "20260419.001", + "rendered_at": "2026-04-19T00:00:00Z", + "rendered_by": "test", + }, + ) + + self.assertEqual(rendered["metadata"]["config_id"], "local_3588_face_debug") + self.assertEqual(rendered["metadata"]["config_version"], "20260419.001") + self.assertEqual(rendered["metadata"]["template"], "pipeline") + self.assertEqual(rendered["metadata"]["profile"], "cam1") + self.assertEqual(rendered["metadata"]["overlays"], []) + self.assertEqual(rendered["metadata"]["rendered_by"], "test") + if __name__ == "__main__": unittest.main() diff --git a/tools/render_config.py b/tools/render_config.py index ab5bfc4..39dcf0d 100644 --- a/tools/render_config.py +++ b/tools/render_config.py @@ -125,7 +125,12 @@ def apply_overlay(root: JsonObject, overlay: JsonObject) -> JsonObject: return out -def render(template_path: Path, profile_path: Path, overlay_paths: list[Path]) -> JsonObject: +def render( + template_path: Path, + profile_path: Path, + overlay_paths: list[Path], + metadata: JsonObject | None = None, +) -> JsonObject: template_doc = load_json(template_path) profile = load_json(profile_path) tpl_name = template_name(template_doc, template_path) @@ -140,6 +145,17 @@ def render(template_path: Path, profile_path: Path, overlay_paths: list[Path]) - for overlay_path in overlay_paths: root = apply_overlay(root, load_json(overlay_path)) + if metadata is not None: + profile_name = str(profile.get("name") or profile_path.stem).strip() + root["metadata"] = { + "template": tpl_name, + "template_path": template_path.as_posix(), + "profile": profile_name, + "profile_path": profile_path.as_posix(), + "overlays": [p.stem for p in overlay_paths], + "overlay_paths": [p.as_posix() for p in overlay_paths], + **copy.deepcopy(metadata), + } return root @@ -149,9 +165,30 @@ def main() -> int: parser.add_argument("--profile", required=True, type=Path) parser.add_argument("--overlay", action="append", default=[], type=Path) parser.add_argument("--out", required=True, type=Path) + parser.add_argument("--config-id", default="") + parser.add_argument("--config-version", default="") + parser.add_argument("--rendered-at", default="") + parser.add_argument("--metadata-json", default="") args = parser.parse_args() - rendered = render(args.template, args.profile, args.overlay) + metadata: JsonObject | None = None + if args.metadata_json: + parsed = json.loads(args.metadata_json) + if not isinstance(parsed, dict): + raise ValueError("--metadata-json must be a JSON object") + metadata = parsed + if args.config_id or args.config_version or args.rendered_at: + metadata = {} if metadata is None else metadata + if args.config_id: + metadata["config_id"] = args.config_id + if args.config_version: + metadata["config_version"] = args.config_version + if args.rendered_at: + metadata["rendered_at"] = args.rendered_at + if metadata is not None: + metadata.setdefault("rendered_by", "tools/render_config.py") + + rendered = render(args.template, args.profile, args.overlay, metadata=metadata) args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text( json.dumps(rendered, ensure_ascii=False, indent=2) + "\n",