mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-01 13:47:51 +00:00
Durability mode implementation (sync_all, sync_quorum, best_effort): - DurabilityMode type with superblock persistence, parse/validate/string - MakeDistributedSync mode-aware barrier enforcement in dist_group_commit - blockerr sentinel package (ErrDurabilityBarrierFailed, ErrDurabilityQuorumLost) - gRPC create path: mode validation, idempotent create consistency, partial cleanup - F1: strict mode rejects partial replica provisioning with cleanup - F3: empty heartbeat does not overwrite persisted strict mode - F4: SCSI error mapping uses errors.Is sentinels (not string matching) - Proto/wire/blockapi/CLI/UI plumbing for durability_mode field - Observability dashboard: cluster health cards + per-volume columns Testrunner platform (YAML-driven integration test framework): - Engine, parser, registry, reporter (JUnit XML + HTML), metrics scraping - 52 registered actions: block, iSCSI, I/O, fault injection, assertions - Baseline regression framework with 7 hard-fail conditions - 15 YAML scenarios (smoke, crash, HA, fault, consistency, snapshot) - 49 unit tests for testrunner internals QA adversarial suite (21 tests, all PASS): - Idempotent create mode/RF mismatch detection - Heartbeat mode downgrade prevention (F3) - sync_all/sync_quorum partial replica enforcement (F1) - Concurrent create race safety - Failover/expand mode preservation - Cleanup resilience when delete fails - Master restart auto-register mode handling - Superblock roundtrip all 3 modes - Validate edge cases (mode×RF matrix) - RequiredReplicas quorum math verification - Sentinel error categorization Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
427 lines
12 KiB
Go
427 lines
12 KiB
Go
package testrunner
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// startTestAgent starts an agent on a random port and returns its base URL.
|
|
func startTestAgent(t *testing.T, cfg AgentConfig) (*Agent, string) {
|
|
t.Helper()
|
|
if cfg.Registry == nil {
|
|
cfg.Registry = NewRegistry()
|
|
}
|
|
agent := NewAgent(cfg)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
go agent.Start(ctx)
|
|
// Wait for listener.
|
|
for i := 0; i < 50; i++ {
|
|
if agent.ListenAddr() != "" {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
if agent.ListenAddr() == "" {
|
|
t.Fatal("agent didn't start")
|
|
}
|
|
baseURL := "http://" + agent.ListenAddr()
|
|
return agent, baseURL
|
|
}
|
|
|
|
func TestAgent_Health(t *testing.T) {
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0})
|
|
|
|
resp, err := http.Get(baseURL + "/health")
|
|
if err != nil {
|
|
t.Fatalf("GET /health: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
|
|
var hr HealthResponse
|
|
json.NewDecoder(resp.Body).Decode(&hr)
|
|
if !hr.OK {
|
|
t.Error("health not OK")
|
|
}
|
|
if hr.AgentID == "" {
|
|
t.Error("empty agent ID")
|
|
}
|
|
if hr.Hostname == "" {
|
|
t.Error("empty hostname")
|
|
}
|
|
}
|
|
|
|
func TestAgent_Health_NoAuth(t *testing.T) {
|
|
// /health should not require auth.
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0, Token: "secret"})
|
|
|
|
resp, err := http.Get(baseURL + "/health")
|
|
if err != nil {
|
|
t.Fatalf("GET /health: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("/health without token should work, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAgent_Auth_Rejection(t *testing.T) {
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0, Token: "secret"})
|
|
|
|
// POST /phase without token → 401.
|
|
body := bytes.NewReader([]byte(`{}`))
|
|
resp, err := http.Post(baseURL+"/phase", "application/json", body)
|
|
if err != nil {
|
|
t.Fatalf("POST /phase: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAgent_Auth_ValidToken(t *testing.T) {
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0, Token: "secret"})
|
|
|
|
req, _ := http.NewRequest("POST", baseURL+"/phase", bytes.NewReader([]byte(`{"phase_index":0, "actions":[], "global_vars":{}}`)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set(AuthTokenHeader, "secret")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("POST /phase: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("expected 200 with valid token, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAgent_Phase_EchoAction(t *testing.T) {
|
|
registry := NewRegistry()
|
|
registry.RegisterFunc("echo_val", TierCore, func(ctx context.Context, actx *ActionContext, act Action) (map[string]string, error) {
|
|
return map[string]string{"value": act.Params["msg"]}, nil
|
|
})
|
|
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0, Registry: registry})
|
|
|
|
phaseReq := PhaseRequest{
|
|
PhaseIndex: 0,
|
|
PhaseName: "test",
|
|
Actions: []Action{
|
|
{Action: "echo_val", SaveAs: "result", Params: map[string]string{"msg": "hello"}},
|
|
},
|
|
GlobalVars: map[string]string{},
|
|
}
|
|
|
|
body, _ := json.Marshal(phaseReq)
|
|
resp, err := http.Post(baseURL+"/phase", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("POST /phase: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var phaseResp PhaseResponse
|
|
json.NewDecoder(resp.Body).Decode(&phaseResp)
|
|
|
|
if phaseResp.Error != "" {
|
|
t.Fatalf("phase error: %s", phaseResp.Error)
|
|
}
|
|
if len(phaseResp.Results) != 1 {
|
|
t.Fatalf("expected 1 result, got %d", len(phaseResp.Results))
|
|
}
|
|
if phaseResp.Results[0].Status != StatusPass {
|
|
t.Errorf("action status = %s", phaseResp.Results[0].Status)
|
|
}
|
|
if phaseResp.NewVars["result"] != "hello" {
|
|
t.Errorf("new_vars[result] = %q, want hello", phaseResp.NewVars["result"])
|
|
}
|
|
}
|
|
|
|
func TestAgent_Phase_FailStopsExecution(t *testing.T) {
|
|
registry := NewRegistry()
|
|
callOrder := []string{}
|
|
registry.RegisterFunc("a1", TierCore, func(ctx context.Context, actx *ActionContext, act Action) (map[string]string, error) {
|
|
callOrder = append(callOrder, "a1")
|
|
return nil, fmt.Errorf("fail")
|
|
})
|
|
registry.RegisterFunc("a2", TierCore, func(ctx context.Context, actx *ActionContext, act Action) (map[string]string, error) {
|
|
callOrder = append(callOrder, "a2")
|
|
return nil, nil
|
|
})
|
|
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0, Registry: registry})
|
|
|
|
phaseReq := PhaseRequest{
|
|
PhaseName: "test",
|
|
Actions: []Action{
|
|
{Action: "a1"},
|
|
{Action: "a2"}, // should not execute
|
|
},
|
|
GlobalVars: map[string]string{},
|
|
}
|
|
|
|
body, _ := json.Marshal(phaseReq)
|
|
resp, _ := http.Post(baseURL+"/phase", "application/json", bytes.NewReader(body))
|
|
defer resp.Body.Close()
|
|
|
|
var phaseResp PhaseResponse
|
|
json.NewDecoder(resp.Body).Decode(&phaseResp)
|
|
|
|
if phaseResp.Error == "" {
|
|
t.Error("expected error from failed action")
|
|
}
|
|
if len(callOrder) != 1 {
|
|
t.Errorf("expected only a1 to run, got %v", callOrder)
|
|
}
|
|
}
|
|
|
|
func TestAgent_Upload_PathSafety(t *testing.T) {
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0})
|
|
|
|
// Attempt path traversal → should be rejected.
|
|
req, _ := http.NewRequest("POST", baseURL+"/upload?path=/tmp/sw-test-runner/../etc/passwd", bytes.NewReader([]byte("evil")))
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("upload: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("expected 403 for path traversal, got %d", resp.StatusCode)
|
|
}
|
|
|
|
// Attempt outside base path.
|
|
req2, _ := http.NewRequest("POST", baseURL+"/upload?path=/etc/evil", bytes.NewReader([]byte("evil")))
|
|
req2.Header.Set("Content-Type", "application/octet-stream")
|
|
resp2, _ := http.DefaultClient.Do(req2)
|
|
defer resp2.Body.Close()
|
|
|
|
if resp2.StatusCode != http.StatusForbidden {
|
|
t.Errorf("expected 403 for outside base path, got %d", resp2.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAgent_Upload_ValidPath(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("upload test requires /tmp on Unix")
|
|
}
|
|
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0})
|
|
|
|
// Create a temp subdir under /tmp/sw-test-runner/.
|
|
uploadDir := "/tmp/sw-test-runner/test-upload"
|
|
os.MkdirAll(uploadDir, 0755)
|
|
t.Cleanup(func() { os.RemoveAll(uploadDir) })
|
|
|
|
uploadPath := filepath.Join(uploadDir, "testfile.bin")
|
|
content := []byte("test binary content")
|
|
|
|
req, _ := http.NewRequest("POST", baseURL+"/upload?path="+uploadPath, bytes.NewReader(content))
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("upload: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var ur UploadResponse
|
|
json.NewDecoder(resp.Body).Decode(&ur)
|
|
if !ur.OK {
|
|
t.Fatalf("upload not OK: %s", ur.Error)
|
|
}
|
|
if ur.Size != int64(len(content)) {
|
|
t.Errorf("size = %d, want %d", ur.Size, len(content))
|
|
}
|
|
|
|
// Verify file contents.
|
|
got, _ := os.ReadFile(uploadPath)
|
|
if string(got) != string(content) {
|
|
t.Errorf("content mismatch")
|
|
}
|
|
}
|
|
|
|
func TestAgent_Exec_DisabledByDefault(t *testing.T) {
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0, AllowExec: false})
|
|
|
|
body, _ := json.Marshal(ExecRequest{Cmd: "echo hi"})
|
|
resp, err := http.Post(baseURL+"/exec", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("POST /exec: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("expected 403 when exec disabled, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAgent_Exec_Enabled(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("exec test requires Unix shell")
|
|
}
|
|
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0, AllowExec: true})
|
|
|
|
body, _ := json.Marshal(ExecRequest{Cmd: "echo hello-exec"})
|
|
resp, err := http.Post(baseURL+"/exec", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("POST /exec: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var er ExecResponse
|
|
json.NewDecoder(resp.Body).Decode(&er)
|
|
if er.ExitCode != 0 {
|
|
t.Errorf("exit code = %d, stderr: %s, error: %s", er.ExitCode, er.Stderr, er.Error)
|
|
}
|
|
if er.Stdout != "hello-exec\n" {
|
|
t.Errorf("stdout = %q", er.Stdout)
|
|
}
|
|
}
|
|
|
|
func TestAgent_Artifacts_PathSafety(t *testing.T) {
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0, Token: "secret"})
|
|
|
|
// Attempt with traversal.
|
|
req, _ := http.NewRequest("GET", baseURL+"/artifacts?dir=/tmp/sw-test-runner/../etc", nil)
|
|
req.Header.Set(AuthTokenHeader, "secret")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("GET /artifacts: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("expected 403 for traversal, got %d", resp.StatusCode)
|
|
}
|
|
|
|
// Attempt outside base path.
|
|
req2, _ := http.NewRequest("GET", baseURL+"/artifacts?dir=/etc", nil)
|
|
req2.Header.Set(AuthTokenHeader, "secret")
|
|
resp2, err := http.DefaultClient.Do(req2)
|
|
if err != nil {
|
|
t.Fatalf("GET /artifacts: %v", err)
|
|
}
|
|
defer resp2.Body.Close()
|
|
|
|
if resp2.StatusCode != http.StatusForbidden {
|
|
t.Errorf("expected 403 for outside base path, got %d", resp2.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAgent_Artifacts_MissingDir(t *testing.T) {
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0})
|
|
|
|
req, _ := http.NewRequest("GET", baseURL+"/artifacts?dir=/tmp/sw-test-runner/nonexistent-"+fmt.Sprintf("%d", time.Now().UnixNano()), nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("GET /artifacts: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("expected 404 for missing dir, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAgent_Artifacts_NoAuth(t *testing.T) {
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0, Token: "secret"})
|
|
|
|
// No auth header should be rejected.
|
|
resp, err := http.Get(baseURL + "/artifacts?dir=/tmp/sw-test-runner/test")
|
|
if err != nil {
|
|
t.Fatalf("GET /artifacts: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAgent_Artifacts_ValidDir(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("artifacts test requires /tmp on Unix")
|
|
}
|
|
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0})
|
|
|
|
// Create test directory with files.
|
|
dir := fmt.Sprintf("/tmp/sw-test-runner/test-artifacts-%d", time.Now().UnixNano())
|
|
os.MkdirAll(dir, 0755)
|
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
|
|
|
os.WriteFile(filepath.Join(dir, "log.txt"), []byte("test log content"), 0644)
|
|
os.WriteFile(filepath.Join(dir, "dmesg.txt"), []byte("kernel messages"), 0644)
|
|
|
|
req, _ := http.NewRequest("GET", baseURL+"/artifacts?dir="+dir, nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("GET /artifacts: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
if ct := resp.Header.Get("Content-Type"); ct != "application/gzip" {
|
|
t.Errorf("Content-Type = %q, want application/gzip", ct)
|
|
}
|
|
|
|
// Verify it's valid gzip+tar.
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if len(body) == 0 {
|
|
t.Fatal("empty response body")
|
|
}
|
|
}
|
|
|
|
func TestAgent_Phase_VarSubstitution(t *testing.T) {
|
|
registry := NewRegistry()
|
|
registry.RegisterFunc("concat", TierCore, func(ctx context.Context, actx *ActionContext, act Action) (map[string]string, error) {
|
|
return map[string]string{"value": act.Params["a"] + "-" + act.Params["b"]}, nil
|
|
})
|
|
|
|
_, baseURL := startTestAgent(t, AgentConfig{Port: 0, Registry: registry})
|
|
|
|
phaseReq := PhaseRequest{
|
|
PhaseName: "test",
|
|
Actions: []Action{
|
|
{Action: "concat", SaveAs: "out", Params: map[string]string{"a": "{{ x }}", "b": "{{ y }}"}},
|
|
},
|
|
GlobalVars: map[string]string{"x": "hello", "y": "world"},
|
|
}
|
|
|
|
body, _ := json.Marshal(phaseReq)
|
|
resp, _ := http.Post(baseURL+"/phase", "application/json", bytes.NewReader(body))
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
var phaseResp PhaseResponse
|
|
json.Unmarshal(respBody, &phaseResp)
|
|
|
|
if phaseResp.NewVars["out"] != "hello-world" {
|
|
t.Errorf("var substitution failed: out = %q", phaseResp.NewVars["out"])
|
|
}
|
|
}
|