From 041ce18ad035a83201f08ecc448a3ba8d6d71e2c Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Sun, 19 Apr 2026 11:24:14 +0800 Subject: [PATCH] Add agent config candidate endpoint --- .../internal/httpapi/config_candidate_test.go | 94 +++++++++++++++++++ agent/internal/httpapi/extras.go | 8 ++ agent/internal/httpapi/server.go | 79 ++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 agent/internal/httpapi/config_candidate_test.go diff --git a/agent/internal/httpapi/config_candidate_test.go b/agent/internal/httpapi/config_candidate_test.go new file mode 100644 index 0000000..ece3807 --- /dev/null +++ b/agent/internal/httpapi/config_candidate_test.go @@ -0,0 +1,94 @@ +package httpapi + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "rk3588sys/agent/internal/config" +) + +func TestHandleConfigCandidateStoresValidatedCandidate(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "media-server.json") + candidatePath := cfgPath + ".candidate.json" + body := []byte(`{"templates":{"tpl":{"nodes":[],"edges":[]}},"instances":[],"metadata":{"config_id":"cfg-preview","config_version":"v2"}}`) + + s := &Server{ + agentCfg: config.AgentConfig{ConfigPath: cfgPath, MaxUploadMB: 1, Token: "test-token"}, + } + req := httptest.NewRequest(http.MethodPut, "/v1/config/candidate", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-RK-Token", "test-token") + rr := httptest.NewRecorder() + + s.handleConfigCandidate(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status code: got %d body=%s", rr.Code, rr.Body.String()) + } + written, err := os.ReadFile(candidatePath) + if err != nil { + t.Fatalf("read candidate: %v", err) + } + if strings.TrimSpace(string(written)) != string(body) { + t.Fatalf("candidate body = %s", written) + } + + var got map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got["path"] != filepath.ToSlash(candidatePath) { + t.Fatalf("path = %v", got["path"]) + } + sum := sha256.Sum256(written) + if got["sha256"] != hex.EncodeToString(sum[:]) { + t.Fatalf("sha256 = %v", got["sha256"]) + } + metadata, ok := got["metadata"].(map[string]any) + if !ok || metadata["config_id"] != "cfg-preview" || metadata["config_version"] != "v2" { + t.Fatalf("metadata = %#v", got["metadata"]) + } + + statusReq := httptest.NewRequest(http.MethodGet, "/v1/config/status", nil) + statusRR := httptest.NewRecorder() + s.handleConfigStatus(statusRR, statusReq) + if statusRR.Code != http.StatusOK { + t.Fatalf("status response code: %d body=%s", statusRR.Code, statusRR.Body.String()) + } + var status map[string]any + if err := json.Unmarshal(statusRR.Body.Bytes(), &status); err != nil { + t.Fatalf("decode status: %v", err) + } + candidate, ok := status["candidate"].(map[string]any) + if !ok || candidate["exists"] != true { + t.Fatalf("candidate status = %#v", status["candidate"]) + } +} + +func TestHandleConfigCandidateRejectsInvalidJSON(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "media-server.json") + s := &Server{agentCfg: config.AgentConfig{ConfigPath: cfgPath, MaxUploadMB: 1, Token: "test-token"}} + + req := httptest.NewRequest(http.MethodPut, "/v1/config/candidate", strings.NewReader(`{"instances":[]`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-RK-Token", "test-token") + rr := httptest.NewRecorder() + + s.handleConfigCandidate(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("status code: got %d body=%s", rr.Code, rr.Body.String()) + } + if _, err := os.Stat(cfgPath + ".candidate.json"); !os.IsNotExist(err) { + t.Fatalf("candidate file should not exist, stat err=%v", err) + } +} diff --git a/agent/internal/httpapi/extras.go b/agent/internal/httpapi/extras.go index 0051f9d..0893a86 100644 --- a/agent/internal/httpapi/extras.go +++ b/agent/internal/httpapi/extras.go @@ -189,6 +189,8 @@ func (s *Server) handleConfigStatus(w http.ResponseWriter, r *http.Request) { current := readConfigFileStatus(s.agentCfg.ConfigPath) lastGoodPath := s.agentCfg.ConfigPath + ".last_good.json" lastGood := readConfigFileStatus(lastGoodPath) + candidatePath := s.configCandidatePath() + candidate := readConfigFileStatus(candidatePath) resp := map[string]any{ "ok": true, @@ -196,6 +198,8 @@ func (s *Server) handleConfigStatus(w http.ResponseWriter, r *http.Request) { "exists": current.Exists, "last_good_path": filepath.ToSlash(lastGoodPath), "last_good": lastGood, + "candidate_path": filepath.ToSlash(candidatePath), + "candidate": candidate, } if current.Exists { resp["size"] = current.Size @@ -225,6 +229,10 @@ func (s *Server) handleConfigStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +func (s *Server) configCandidatePath() string { + return s.agentCfg.ConfigPath + ".candidate.json" +} + func readConfigFileStatus(path string) configFileStatus { out := configFileStatus{Path: filepath.ToSlash(path)} if strings.TrimSpace(path) == "" { diff --git a/agent/internal/httpapi/server.go b/agent/internal/httpapi/server.go index 6ff0e17..576bf75 100644 --- a/agent/internal/httpapi/server.go +++ b/agent/internal/httpapi/server.go @@ -109,6 +109,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/candidate", s.handleConfigCandidate) mux.HandleFunc("/v1/config", s.handleConfig) mux.HandleFunc("/v1/config/ui/schema", s.handleConfigUISchema) mux.HandleFunc("/v1/config/ui/state", s.handleConfigUIState) @@ -169,6 +170,58 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +func (s *Server) handleConfigCandidate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if !s.authorize(r, true) { + errorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + if mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mt != "application/json" { + errorJSON(w, http.StatusBadRequest, "validation failed: Content-Type must be application/json") + return + } + + maxBytes := int64(s.agentCfg.MaxUploadMB) * 1024 * 1024 + if maxBytes <= 0 { + maxBytes = int64(20 << 20) + } + r.Body = http.MaxBytesReader(w, r.Body, maxBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + if strings.Contains(err.Error(), "request body too large") { + errorJSON(w, http.StatusRequestEntityTooLarge, "payload too large") + return + } + errorJSON(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + root, err := validateRootConfigJSON(body) + if err != nil { + errorJSON(w, http.StatusBadRequest, err.Error()) + return + } + + candidatePath := s.configCandidatePath() + if err := files.WriteFileAtomic(candidatePath, append(body, '\n'), 0o644); err != nil { + s.recordAudit(r, "config.candidate.update", false, err.Error()) + errorJSON(w, http.StatusInternalServerError, "internal error: write candidate failed: "+err.Error()) + return + } + st := readConfigFileStatus(candidatePath) + s.recordAudit(r, "config.candidate.update", true, candidatePath) + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "path": filepath.ToSlash(candidatePath), + "sha256": st.Sha256, + "size": st.Size, + "mtime_ms": st.MtimeMS, + "metadata": root.Metadata, + }) +} + func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -238,6 +291,32 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { } } +type rootConfigDocument struct { + Templates map[string]any `json:"templates"` + Instances []any `json:"instances"` + Metadata map[string]any `json:"metadata"` +} + +func validateRootConfigJSON(body []byte) (rootConfigDocument, error) { + if len(strings.TrimSpace(string(body))) == 0 { + return rootConfigDocument{}, errors.New("validation failed: empty body") + } + var root rootConfigDocument + if err := json.Unmarshal(body, &root); err != nil { + return rootConfigDocument{}, fmt.Errorf("invalid json: %w", err) + } + if len(root.Templates) == 0 { + return rootConfigDocument{}, errors.New("validation failed: templates must be a non-empty object") + } + if root.Instances == nil { + return rootConfigDocument{}, errors.New("validation failed: instances must be an array") + } + if len(root.Metadata) == 0 { + return rootConfigDocument{}, errors.New("validation failed: metadata must be a non-empty object") + } + return root, nil +} + func (s *Server) applyRootConfigBytes(ctx context.Context, body []byte) error { if err := files.WriteFileAtomic(s.agentCfg.ConfigPath, append(body, '\n'), 0o644); err != nil { return fmt.Errorf("write config failed: %w", err)