Add agent config candidate endpoint

This commit is contained in:
tian 2026-04-19 11:24:14 +08:00
parent ace8dcde72
commit 041ce18ad0
3 changed files with 181 additions and 0 deletions

View File

@ -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)
}
}

View File

@ -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) == "" {

View File

@ -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)