Add agent config candidate endpoint
This commit is contained in:
parent
ace8dcde72
commit
041ce18ad0
94
agent/internal/httpapi/config_candidate_test.go
Normal file
94
agent/internal/httpapi/config_candidate_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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) == "" {
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user