Files
seaweedfs/weed/storage/blockvol/testrunner/actions/devops.go
T
Ping QiuandClaude Opus 4.6 da1b81d1c9 feat: CP8-3-1 durability modes + testrunner platform + 21 adversarial tests
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>
2026-03-06 01:06:51 -08:00

263 lines
8.3 KiB
Go

package actions
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
tr "github.com/seaweedfs/seaweedfs/weed/storage/blockvol/testrunner"
)
// RegisterDevOpsActions registers SeaweedFS cluster management actions.
func RegisterDevOpsActions(r *tr.Registry) {
r.RegisterFunc("build_deploy_weed", tr.TierDevOps, buildDeployWeed)
r.RegisterFunc("start_weed_master", tr.TierDevOps, startWeedMaster)
r.RegisterFunc("start_weed_volume", tr.TierDevOps, startWeedVolume)
r.RegisterFunc("stop_weed", tr.TierDevOps, stopWeed)
r.RegisterFunc("wait_cluster_ready", tr.TierDevOps, waitClusterReady)
r.RegisterFunc("create_block_volume", tr.TierDevOps, createBlockVolume)
r.RegisterFunc("cluster_status", tr.TierDevOps, clusterStatus)
}
// buildDeployWeed cross-compiles the weed binary and uploads to all nodes.
func buildDeployWeed(ctx context.Context, actx *tr.ActionContext, act tr.Action) (map[string]string, error) {
repoDir := actx.Vars["repo_dir"]
if repoDir == "" {
return nil, fmt.Errorf("build_deploy_weed: repo_dir not set in env")
}
actx.Log(" cross-compiling weed binary...")
localBin := repoDir + "/weed-linux"
buildCmd := fmt.Sprintf("cd %s && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o weed-linux ./weed/command", repoDir)
ln := tr.NewLocalNode("build-host")
_, stderr, code, err := ln.Run(ctx, buildCmd)
if err != nil || code != 0 {
return nil, fmt.Errorf("build_deploy_weed: code=%d stderr=%s err=%v", code, stderr, err)
}
// Upload to agents if coordinator mode.
if actx.Coordinator != nil {
for _, agentName := range actx.Coordinator.AgentNames() {
actx.Log(" uploading weed to agent %s...", agentName)
if err := actx.Coordinator.UploadToAgent(ctx, agentName, localBin, tr.UploadBasePath+"weed"); err != nil {
return nil, fmt.Errorf("upload weed to %s: %w", agentName, err)
}
}
return nil, nil
}
// SSH mode: deploy to all nodes.
for nodeName, nodeRunner := range actx.Nodes {
actx.Log(" deploying weed to node %s...", nodeName)
nodeRunner.Run(ctx, fmt.Sprintf("mkdir -p %s", tr.UploadBasePath))
if err := nodeRunner.Upload(localBin, tr.UploadBasePath+"weed"); err != nil {
return nil, fmt.Errorf("deploy weed to %s: %w", nodeName, err)
}
nodeRunner.Run(ctx, fmt.Sprintf("chmod +x %sweed", tr.UploadBasePath))
}
return nil, nil
}
// startWeedMaster starts a weed master process on the given node.
func startWeedMaster(ctx context.Context, actx *tr.ActionContext, act tr.Action) (map[string]string, error) {
node, err := getNode(actx, act.Node)
if err != nil {
return nil, fmt.Errorf("start_weed_master: %w", err)
}
port := act.Params["port"]
if port == "" {
port = "9333"
}
dir := act.Params["dir"]
if dir == "" {
dir = "/tmp/sw-weed-master"
}
extraArgs := act.Params["extra_args"]
// Ensure directory exists.
node.RunRoot(ctx, fmt.Sprintf("mkdir -p %s", dir))
cmd := fmt.Sprintf("setsid %sweed master -port=%s -mdir=%s %s </dev/null >%s/master.log 2>&1 & echo $!",
tr.UploadBasePath, port, dir, extraArgs, dir)
stdout, stderr, code, err := node.RunRoot(ctx, cmd)
if err != nil || code != 0 {
return nil, fmt.Errorf("start_weed_master: code=%d stderr=%s err=%v", code, stderr, err)
}
pid := strings.TrimSpace(stdout)
actx.Log(" weed master started on port %s (PID %s)", port, pid)
return map[string]string{"value": pid}, nil
}
// startWeedVolume starts a weed volume process on the given node.
func startWeedVolume(ctx context.Context, actx *tr.ActionContext, act tr.Action) (map[string]string, error) {
node, err := getNode(actx, act.Node)
if err != nil {
return nil, fmt.Errorf("start_weed_volume: %w", err)
}
port := act.Params["port"]
if port == "" {
port = "8080"
}
master := act.Params["master"]
if master == "" {
return nil, fmt.Errorf("start_weed_volume: master param required")
}
dir := act.Params["dir"]
if dir == "" {
dir = "/tmp/sw-weed-volume"
}
extraArgs := act.Params["extra_args"]
node.RunRoot(ctx, fmt.Sprintf("mkdir -p %s", dir))
cmd := fmt.Sprintf("setsid %sweed volume -port=%s -mserver=%s -dir=%s %s </dev/null >%s/volume.log 2>&1 & echo $!",
tr.UploadBasePath, port, master, dir, extraArgs, dir)
stdout, stderr, code, err := node.RunRoot(ctx, cmd)
if err != nil || code != 0 {
return nil, fmt.Errorf("start_weed_volume: code=%d stderr=%s err=%v", code, stderr, err)
}
pid := strings.TrimSpace(stdout)
actx.Log(" weed volume started on port %s (PID %s)", port, pid)
return map[string]string{"value": pid}, nil
}
// stopWeed stops a weed process by PID.
func stopWeed(ctx context.Context, actx *tr.ActionContext, act tr.Action) (map[string]string, error) {
node, err := getNode(actx, act.Node)
if err != nil {
return nil, fmt.Errorf("stop_weed: %w", err)
}
pid := act.Params["pid"]
if pid == "" {
return nil, fmt.Errorf("stop_weed: pid param required")
}
// Graceful kill first, then force after 5s.
_, _, _, _ = node.RunRoot(ctx, fmt.Sprintf("kill %s", pid))
deadline := time.After(5 * time.Second)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-deadline:
node.RunRoot(ctx, fmt.Sprintf("kill -9 %s", pid))
actx.Log(" force-killed PID %s", pid)
return nil, nil
case <-ticker.C:
_, _, code, _ := node.Run(ctx, fmt.Sprintf("kill -0 %s 2>/dev/null", pid))
if code != 0 {
actx.Log(" PID %s exited gracefully", pid)
return nil, nil
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
// waitClusterReady polls the master until IsLeader is true.
func waitClusterReady(ctx context.Context, actx *tr.ActionContext, act tr.Action) (map[string]string, error) {
node, err := getNode(actx, act.Node)
if err != nil {
return nil, fmt.Errorf("wait_cluster_ready: %w", err)
}
masterURL := act.Params["master_url"]
if masterURL == "" {
return nil, fmt.Errorf("wait_cluster_ready: master_url param required")
}
timeout := 30 * time.Second
if t, ok := act.Params["timeout"]; ok {
if d, err := parseDuration(t); err == nil {
timeout = d
}
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-timeoutCtx.Done():
return nil, fmt.Errorf("wait_cluster_ready: timeout after %s", timeout)
case <-ticker.C:
cmd := fmt.Sprintf("curl -s %s/cluster/status 2>/dev/null", masterURL)
stdout, _, code, err := node.Run(timeoutCtx, cmd)
if err != nil || code != 0 {
continue
}
if strings.Contains(stdout, `"IsLeader":true`) || strings.Contains(stdout, `"isLeader":true`) {
actx.Log(" cluster ready at %s", masterURL)
return map[string]string{"value": stdout}, nil
}
}
}
}
// createBlockVolume creates a block volume via the master assign API.
func createBlockVolume(ctx context.Context, actx *tr.ActionContext, act tr.Action) (map[string]string, error) {
node, err := getNode(actx, act.Node)
if err != nil {
return nil, fmt.Errorf("create_block_volume: %w", err)
}
masterURL := act.Params["master_url"]
if masterURL == "" {
return nil, fmt.Errorf("create_block_volume: master_url param required")
}
size := act.Params["size"]
if size == "" {
size = "1g"
}
cmd := fmt.Sprintf("curl -s -X POST '%s/vol/assign?type=block&size=%s' 2>/dev/null", masterURL, size)
stdout, stderr, code, err := node.Run(ctx, cmd)
if err != nil || code != 0 {
return nil, fmt.Errorf("create_block_volume: code=%d stderr=%s err=%v", code, stderr, err)
}
return map[string]string{"value": strings.TrimSpace(stdout)}, nil
}
// clusterStatus fetches the full cluster status JSON.
func clusterStatus(ctx context.Context, actx *tr.ActionContext, act tr.Action) (map[string]string, error) {
node, err := getNode(actx, act.Node)
if err != nil {
return nil, fmt.Errorf("cluster_status: %w", err)
}
masterURL := act.Params["master_url"]
if masterURL == "" {
return nil, fmt.Errorf("cluster_status: master_url param required")
}
cmd := fmt.Sprintf("curl -s %s/cluster/status 2>/dev/null", masterURL)
stdout, stderr, code, err := node.Run(ctx, cmd)
if err != nil || code != 0 {
return nil, fmt.Errorf("cluster_status: code=%d stderr=%s err=%v", code, stderr, err)
}
// Validate it's JSON.
var js json.RawMessage
if err := json.Unmarshal([]byte(stdout), &js); err != nil {
return nil, fmt.Errorf("cluster_status: invalid JSON response: %s", stdout)
}
return map[string]string{"value": strings.TrimSpace(stdout)}, nil
}