mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-01 13:47:51 +00:00
CP10-3 Tier 1 optimizations (T1-T4): - TCP_NODELAY + 256KB socket buffers on NVMe/TCP connections - Response batching: all C2H data chunks + CapsuleResp in single flush - Tiered buffer pool (4KB/64KB/256KB sync.Pool) for write payloads - Configurable MaxH2CDataLength wiring through controller/IC/chunking BUG-CP103-1: NVMe write retry with jittered backoff for transient WAL pressure - writeWithRetry() with bounded backoff [50/200/800ms] - throttleOnWALPressure() pre-write delay above 90% WAL usage - WALPressureProvider interface + NVMeAdapter.WALPressure() BUG-CP103-2: Volume-level WAL admission control - WALAdmission with counting semaphore (max concurrent writers) - Soft watermark (0.7): small delay to desynchronize herd - Hard watermark (0.9): block until flusher drains - Single-deadline budget shared across watermark wait + semaphore - Close-aware during both watermark and semaphore waits - Wired into BlockVol.WriteLBA() and Trim() Benchmark platform enhancements: - NVMe benchmark actions and scenarios (A/B, CW sweep, IOQ sweep) - Database benchmark actions (SQLite, pgbench) - K8s operator QA reconciler tests - New testrunner scenarios for HA, fault injection, CSI lifecycle Test counts: 213 NVMe + 625 engine + operator + testrunner tests, all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
699 lines
18 KiB
Go
699 lines
18 KiB
Go
package testrunner
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/testrunner/infra"
|
|
)
|
|
|
|
// Agent runs on each test node, executing actions locally on behalf of the coordinator.
|
|
type Agent struct {
|
|
ID string
|
|
Hostname string
|
|
Port int
|
|
CoordinatorURL string
|
|
Token string
|
|
AllowExec bool
|
|
Persistent bool // stay running across coordinator restarts
|
|
Nodes []string // node names this agent handles
|
|
|
|
registry *Registry
|
|
localNode *LocalNode
|
|
startTime time.Time
|
|
log *log.Logger
|
|
|
|
mu sync.Mutex
|
|
server *http.Server
|
|
listener net.Listener
|
|
}
|
|
|
|
// AgentConfig holds configuration for creating an Agent.
|
|
type AgentConfig struct {
|
|
Hostname string
|
|
Port int
|
|
CoordinatorURL string
|
|
Token string
|
|
AllowExec bool
|
|
Persistent bool // stay running, re-register with coordinator
|
|
Nodes []string
|
|
Registry *Registry
|
|
Logger *log.Logger
|
|
}
|
|
|
|
// NewAgent creates a new Agent with the given configuration.
|
|
func NewAgent(cfg AgentConfig) *Agent {
|
|
hostname := cfg.Hostname
|
|
if hostname == "" {
|
|
hostname, _ = os.Hostname()
|
|
}
|
|
|
|
agentID := fmt.Sprintf("agent-%s-%d", hostname, os.Getpid())
|
|
|
|
logger := cfg.Logger
|
|
if logger == nil {
|
|
logger = log.New(os.Stderr, "[agent] ", log.LstdFlags)
|
|
}
|
|
|
|
return &Agent{
|
|
ID: agentID,
|
|
Hostname: hostname,
|
|
Port: cfg.Port,
|
|
CoordinatorURL: cfg.CoordinatorURL,
|
|
Token: cfg.Token,
|
|
AllowExec: cfg.AllowExec,
|
|
Persistent: cfg.Persistent,
|
|
Nodes: cfg.Nodes,
|
|
registry: cfg.Registry,
|
|
localNode: NewLocalNode(hostname),
|
|
startTime: time.Now(),
|
|
log: logger,
|
|
}
|
|
}
|
|
|
|
// Start begins listening for coordinator requests. Blocks until the server is shut down.
|
|
func (a *Agent) Start(ctx context.Context) error {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/health", a.handleHealth)
|
|
mux.HandleFunc("/phase", a.authMiddleware(a.handlePhase))
|
|
mux.HandleFunc("/upload", a.authMiddleware(a.handleUpload))
|
|
mux.HandleFunc("/artifacts", a.authMiddleware(a.handleArtifacts))
|
|
mux.HandleFunc("/exec", a.authMiddleware(a.handleExec))
|
|
|
|
addr := fmt.Sprintf(":%d", a.Port)
|
|
ln, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return fmt.Errorf("listen %s: %w", addr, err)
|
|
}
|
|
|
|
a.mu.Lock()
|
|
a.listener = ln
|
|
a.server = &http.Server{Handler: mux}
|
|
a.mu.Unlock()
|
|
|
|
a.log.Printf("agent %s listening on %s (root=%v, exec=%v)", a.ID, addr, a.localNode.IsRoot(), a.AllowExec)
|
|
|
|
// Register with coordinator if URL is set.
|
|
if a.CoordinatorURL != "" {
|
|
if a.Persistent {
|
|
go a.registrationLoop(ctx)
|
|
} else {
|
|
go a.registerWithCoordinator()
|
|
}
|
|
}
|
|
|
|
// Shutdown on context cancellation.
|
|
go func() {
|
|
<-ctx.Done()
|
|
a.Stop()
|
|
}()
|
|
|
|
if err := a.server.Serve(ln); err != nil && err != http.ErrServerClosed {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Stop gracefully shuts down the agent HTTP server.
|
|
func (a *Agent) Stop() {
|
|
a.mu.Lock()
|
|
srv := a.server
|
|
a.mu.Unlock()
|
|
|
|
if srv != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
srv.Shutdown(ctx)
|
|
}
|
|
}
|
|
|
|
// ListenAddr returns the address the agent is listening on (useful in tests).
|
|
func (a *Agent) ListenAddr() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.listener != nil {
|
|
return a.listener.Addr().String()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (a *Agent) registerWithCoordinator() {
|
|
req := RegisterRequest{
|
|
AgentID: a.ID,
|
|
Hostname: a.Hostname,
|
|
IP: a.detectIP(),
|
|
Port: a.Port,
|
|
Nodes: a.Nodes,
|
|
}
|
|
|
|
// Detect capabilities.
|
|
var caps []string
|
|
if a.localNode.IsRoot() {
|
|
caps = append(caps, "root")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if _, _, code, err := a.localNode.Run(ctx, "which iscsiadm"); err == nil && code == 0 {
|
|
caps = append(caps, "iscsi")
|
|
}
|
|
if _, _, code, err := a.localNode.Run(ctx, "which fio"); err == nil && code == 0 {
|
|
caps = append(caps, "fio")
|
|
}
|
|
req.Capabilities = caps
|
|
|
|
body, _ := json.Marshal(req)
|
|
url := strings.TrimRight(a.CoordinatorURL, "/") + "/register"
|
|
|
|
// Retry registration up to 60s with 2s intervals.
|
|
deadline := time.Now().Add(60 * time.Second)
|
|
for attempt := 1; ; attempt++ {
|
|
httpReq, _ := http.NewRequest("POST", url, strings.NewReader(string(body)))
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
if a.Token != "" {
|
|
httpReq.Header.Set(AuthTokenHeader, a.Token)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(httpReq)
|
|
if err != nil {
|
|
if time.Now().After(deadline) {
|
|
a.log.Printf("register failed after retries: %v", err)
|
|
return
|
|
}
|
|
a.log.Printf("register attempt %d failed: %v (retrying...)", attempt, err)
|
|
time.Sleep(2 * time.Second)
|
|
continue
|
|
}
|
|
|
|
var rr RegisterResponse
|
|
json.NewDecoder(resp.Body).Decode(&rr)
|
|
resp.Body.Close()
|
|
if rr.OK {
|
|
a.log.Printf("registered with coordinator (index=%d, total=%d)", rr.AgentIndex, rr.TotalAgents)
|
|
} else {
|
|
a.log.Printf("registration rejected: %s", rr.Error)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// registrationLoop continuously re-registers with the coordinator.
|
|
// Used in persistent mode so the agent survives coordinator restarts.
|
|
func (a *Agent) registrationLoop(ctx context.Context) {
|
|
req := a.buildRegisterRequest()
|
|
body, _ := json.Marshal(req)
|
|
url := strings.TrimRight(a.CoordinatorURL, "/") + "/register"
|
|
|
|
registered := false
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
httpReq, _ := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(body)))
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
if a.Token != "" {
|
|
httpReq.Header.Set(AuthTokenHeader, a.Token)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(httpReq)
|
|
if err != nil {
|
|
if registered {
|
|
a.log.Printf("coordinator unreachable, will retry: %v", err)
|
|
registered = false
|
|
}
|
|
sleepCtx(ctx, 5*time.Second)
|
|
continue
|
|
}
|
|
|
|
var rr RegisterResponse
|
|
json.NewDecoder(resp.Body).Decode(&rr)
|
|
resp.Body.Close()
|
|
|
|
if rr.OK {
|
|
if !registered {
|
|
a.log.Printf("registered with coordinator (index=%d, total=%d)", rr.AgentIndex, rr.TotalAgents)
|
|
registered = true
|
|
}
|
|
sleepCtx(ctx, 10*time.Second)
|
|
} else {
|
|
a.log.Printf("registration rejected: %s", rr.Error)
|
|
sleepCtx(ctx, 5*time.Second)
|
|
}
|
|
}
|
|
}
|
|
|
|
// buildRegisterRequest creates the registration payload with capability detection.
|
|
func (a *Agent) buildRegisterRequest() RegisterRequest {
|
|
req := RegisterRequest{
|
|
AgentID: a.ID,
|
|
Hostname: a.Hostname,
|
|
IP: a.detectIP(),
|
|
Port: a.Port,
|
|
Nodes: a.Nodes,
|
|
}
|
|
var caps []string
|
|
if a.localNode.IsRoot() {
|
|
caps = append(caps, "root")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if _, _, code, err := a.localNode.Run(ctx, "which iscsiadm"); err == nil && code == 0 {
|
|
caps = append(caps, "iscsi")
|
|
}
|
|
if _, _, code, err := a.localNode.Run(ctx, "which fio"); err == nil && code == 0 {
|
|
caps = append(caps, "fio")
|
|
}
|
|
req.Capabilities = caps
|
|
return req
|
|
}
|
|
|
|
// sleepCtx sleeps for d or until ctx is cancelled.
|
|
func sleepCtx(ctx context.Context, d time.Duration) {
|
|
select {
|
|
case <-time.After(d):
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
|
|
func (a *Agent) detectIP() string {
|
|
addrs, err := net.InterfaceAddrs()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, addr := range addrs {
|
|
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() && ipNet.IP.To4() != nil {
|
|
return ipNet.IP.String()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// authMiddleware wraps a handler with token authentication.
|
|
func (a *Agent) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if a.Token != "" {
|
|
token := r.Header.Get(AuthTokenHeader)
|
|
if token != a.Token {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid auth token"})
|
|
return
|
|
}
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// GET /health
|
|
func (a *Agent) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
resp := HealthResponse{
|
|
OK: true,
|
|
AgentID: a.ID,
|
|
UptimeS: int64(time.Since(a.startTime).Seconds()),
|
|
HasRoot: a.localNode.IsRoot(),
|
|
Hostname: a.Hostname,
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// POST /phase
|
|
func (a *Agent) handlePhase(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req PhaseRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, PhaseResponse{Error: fmt.Sprintf("decode: %v", err)})
|
|
return
|
|
}
|
|
|
|
a.log.Printf("phase %d (%s): %d actions", req.PhaseIndex, req.PhaseName, len(req.Actions))
|
|
|
|
resp := a.executePhase(r.Context(), &req)
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// executePhase runs actions sequentially using the local engine.
|
|
func (a *Agent) executePhase(ctx context.Context, req *PhaseRequest) PhaseResponse {
|
|
resp := PhaseResponse{
|
|
PhaseIndex: req.PhaseIndex,
|
|
NewVars: make(map[string]string),
|
|
}
|
|
|
|
// Build action context.
|
|
actx := &ActionContext{
|
|
Scenario: req.Scenario,
|
|
Nodes: make(map[string]NodeRunner),
|
|
Targets: make(map[string]TargetRunner),
|
|
Vars: make(map[string]string),
|
|
Log: func(format string, args ...interface{}) { a.log.Printf(format, args...) },
|
|
}
|
|
|
|
// Copy global vars.
|
|
for k, v := range req.GlobalVars {
|
|
actx.Vars[k] = v
|
|
}
|
|
|
|
// Create infra.Node in native mode for local command execution.
|
|
nativeNode := &infra.Node{IsNative: true}
|
|
|
|
// Map agent's nodes to native node runner.
|
|
for _, nodeName := range a.Nodes {
|
|
actx.Nodes[nodeName] = nativeNode
|
|
}
|
|
|
|
// Set up targets for nodes this agent handles.
|
|
myNodes := make(map[string]bool)
|
|
for _, n := range a.Nodes {
|
|
myNodes[n] = true
|
|
}
|
|
if req.Scenario != nil {
|
|
// Map node names to agents via topology.
|
|
nodeToAgentName := make(map[string]string)
|
|
for nodeName, nodeSpec := range req.Scenario.Topology.Nodes {
|
|
nodeToAgentName[nodeName] = nodeSpec.Agent
|
|
}
|
|
for tgtName, tgtSpec := range req.Scenario.Targets {
|
|
agentName := nodeToAgentName[tgtSpec.Node]
|
|
if !myNodes[agentName] {
|
|
continue
|
|
}
|
|
htSpec := infra.HATargetSpec{
|
|
VolSize: tgtSpec.VolSize,
|
|
WALSize: tgtSpec.WALSize,
|
|
IQN: tgtSpec.IQN(),
|
|
ISCSIPort: tgtSpec.ISCSIPort,
|
|
AdminPort: tgtSpec.AdminPort,
|
|
ReplicaDataPort: tgtSpec.ReplicaDataPort,
|
|
ReplicaCtrlPort: tgtSpec.ReplicaCtrlPort,
|
|
RebuildPort: tgtSpec.RebuildPort,
|
|
TPGID: tgtSpec.TPGID,
|
|
NvmePort: tgtSpec.NvmePort,
|
|
NQN: tgtSpec.NQN(),
|
|
MaxConcurrentWrites: tgtSpec.MaxConcurrentWrites,
|
|
NvmeIOQueues: tgtSpec.NvmeIOQueues,
|
|
}
|
|
actx.Targets[tgtName] = infra.NewHATargetFromSpec(nativeNode, tgtName, htSpec)
|
|
}
|
|
// Also map topology node names to native node if this agent handles them.
|
|
for nodeName, nodeSpec := range req.Scenario.Topology.Nodes {
|
|
if myNodes[nodeSpec.Agent] {
|
|
actx.Nodes[nodeName] = nativeNode
|
|
}
|
|
}
|
|
}
|
|
|
|
engine := NewEngine(a.registry, actx.Log)
|
|
|
|
for _, act := range req.Actions {
|
|
start := time.Now()
|
|
resolved := resolveAction(act, actx.Vars)
|
|
yamlDef := marshalActionYAML(resolved)
|
|
|
|
handler, err := engine.registry.Get(resolved.Action)
|
|
if err != nil {
|
|
resp.Results = append(resp.Results, ActionResult{
|
|
Action: resolved.Action,
|
|
Status: StatusFail,
|
|
Duration: time.Since(start),
|
|
Error: err.Error(),
|
|
})
|
|
resp.Error = err.Error()
|
|
return resp
|
|
}
|
|
|
|
// Handle delay param.
|
|
if d, ok := resolved.Params["delay"]; ok {
|
|
if dur, err := time.ParseDuration(d); err == nil {
|
|
select {
|
|
case <-time.After(dur):
|
|
case <-ctx.Done():
|
|
resp.Error = ctx.Err().Error()
|
|
return resp
|
|
}
|
|
}
|
|
}
|
|
|
|
output, execErr := handler.Execute(ctx, actx, resolved)
|
|
|
|
ar := ActionResult{
|
|
Action: resolved.Action,
|
|
Duration: time.Since(start),
|
|
YAML: yamlDef,
|
|
}
|
|
|
|
if execErr != nil {
|
|
ar.Status = StatusFail
|
|
ar.Error = execErr.Error()
|
|
if !act.IgnoreError {
|
|
resp.Results = append(resp.Results, ar)
|
|
resp.Error = execErr.Error()
|
|
return resp
|
|
}
|
|
ar.Status = StatusPass
|
|
} else {
|
|
ar.Status = StatusPass
|
|
}
|
|
|
|
// Store save_as and __ vars.
|
|
if resolved.SaveAs != "" && output != nil {
|
|
if v, ok := output["value"]; ok {
|
|
actx.Vars[resolved.SaveAs] = v
|
|
resp.NewVars[resolved.SaveAs] = v
|
|
}
|
|
}
|
|
if output != nil {
|
|
for k, v := range output {
|
|
if strings.HasPrefix(k, "__") {
|
|
actx.Vars[k] = v
|
|
resp.NewVars[k] = v
|
|
}
|
|
}
|
|
if v, ok := output["value"]; ok {
|
|
ar.Output = truncate(v, 4096)
|
|
}
|
|
}
|
|
|
|
resp.Results = append(resp.Results, ar)
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
// POST /upload — streaming binary upload with path safety.
|
|
func (a *Agent) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
path := r.URL.Query().Get("path")
|
|
if path == "" {
|
|
writeJSON(w, http.StatusBadRequest, UploadResponse{Error: "path query parameter required"})
|
|
return
|
|
}
|
|
|
|
// Path safety: must start with UploadBasePath and no traversal.
|
|
if !isPathSafe(path) {
|
|
writeJSON(w, http.StatusForbidden, UploadResponse{Error: fmt.Sprintf("path must start with %s and contain no '..'", UploadBasePath)})
|
|
return
|
|
}
|
|
|
|
// Ensure directory exists.
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, UploadResponse{Error: fmt.Sprintf("mkdir: %v", err)})
|
|
return
|
|
}
|
|
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, UploadResponse{Error: fmt.Sprintf("create file: %v", err)})
|
|
return
|
|
}
|
|
|
|
n, err := io.Copy(f, r.Body)
|
|
f.Close()
|
|
if err != nil {
|
|
os.Remove(path)
|
|
writeJSON(w, http.StatusInternalServerError, UploadResponse{Error: fmt.Sprintf("write: %v", err)})
|
|
return
|
|
}
|
|
|
|
a.log.Printf("uploaded %d bytes to %s", n, path)
|
|
writeJSON(w, http.StatusOK, UploadResponse{OK: true, Size: n, Path: path})
|
|
}
|
|
|
|
// GET /artifacts — stream a directory as tar.gz.
|
|
func (a *Agent) handleArtifacts(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
dir := r.URL.Query().Get("dir")
|
|
if dir == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "dir query parameter required"})
|
|
return
|
|
}
|
|
|
|
// Path safety: must be under UploadBasePath, no traversal.
|
|
if !isPathSafe(dir) {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": fmt.Sprintf("dir must be under %s with no '..'", UploadBasePath)})
|
|
return
|
|
}
|
|
|
|
info, err := os.Stat(dir)
|
|
if err != nil || !info.IsDir() {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "directory not found"})
|
|
return
|
|
}
|
|
|
|
// Check if directory has any files.
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil || len(entries) == 0 {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "directory empty"})
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/gzip")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
gw := gzip.NewWriter(w)
|
|
defer gw.Close()
|
|
tw := tar.NewWriter(gw)
|
|
defer tw.Close()
|
|
|
|
filepath.Walk(dir, func(path string, fi os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil // skip unreadable files
|
|
}
|
|
if fi.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
// Relative path inside the tar.
|
|
rel, err := filepath.Rel(dir, path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
// Normalize to forward slashes for tar.
|
|
rel = filepath.ToSlash(rel)
|
|
|
|
hdr := &tar.Header{
|
|
Name: rel,
|
|
Size: fi.Size(),
|
|
Mode: int64(fi.Mode()),
|
|
ModTime: fi.ModTime(),
|
|
}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
return err
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer f.Close()
|
|
io.Copy(tw, f)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// POST /exec — ad-hoc command execution (disabled by default).
|
|
func (a *Agent) handleExec(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if !a.AllowExec {
|
|
writeJSON(w, http.StatusForbidden, ExecResponse{Error: "exec endpoint disabled; start agent with --allow-exec"})
|
|
return
|
|
}
|
|
|
|
var req ExecRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, ExecResponse{Error: fmt.Sprintf("decode: %v", err)})
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
var stdout, stderr string
|
|
var exitCode int
|
|
var err error
|
|
|
|
if req.Root {
|
|
stdout, stderr, exitCode, err = a.localNode.RunRoot(ctx, req.Cmd)
|
|
} else {
|
|
stdout, stderr, exitCode, err = a.localNode.Run(ctx, req.Cmd)
|
|
}
|
|
|
|
if err != nil {
|
|
writeJSON(w, http.StatusOK, ExecResponse{
|
|
Stdout: stdout,
|
|
Stderr: stderr,
|
|
ExitCode: -1,
|
|
Error: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, ExecResponse{
|
|
Stdout: stdout,
|
|
Stderr: stderr,
|
|
ExitCode: exitCode,
|
|
})
|
|
}
|
|
|
|
// isPathSafe checks that a path is under UploadBasePath and has no traversal.
|
|
func isPathSafe(p string) bool {
|
|
if strings.Contains(p, "..") {
|
|
return false
|
|
}
|
|
// Use forward-slash path cleaning (Unix paths, even on Windows coordinator).
|
|
cleaned := posixClean(p)
|
|
return strings.HasPrefix(cleaned, UploadBasePath)
|
|
}
|
|
|
|
// posixClean normalizes a path using forward slashes (Unix convention).
|
|
func posixClean(p string) string {
|
|
// Replace backslashes, then clean.
|
|
p = strings.ReplaceAll(p, "\\", "/")
|
|
// Remove double slashes.
|
|
for strings.Contains(p, "//") {
|
|
p = strings.ReplaceAll(p, "//", "/")
|
|
}
|
|
// Remove trailing slash (unless root).
|
|
if len(p) > 1 && strings.HasSuffix(p, "/") {
|
|
p = p[:len(p)-1]
|
|
}
|
|
return p
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|