Files
seaweedfs/weed/storage/blockvol/testrunner/local_node.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

105 lines
2.9 KiB
Go

package testrunner
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// LocalNode implements NodeRunner by executing commands locally via os/exec.
// Used by agents that run on the same machine as the targets they manage.
type LocalNode struct {
hostname string
isRoot bool
}
// NewLocalNode creates a LocalNode, detecting root status.
func NewLocalNode(hostname string) *LocalNode {
n := &LocalNode{hostname: hostname}
n.isRoot = n.detectRoot()
return n
}
// Hostname returns the node's hostname.
func (n *LocalNode) Hostname() string { return n.hostname }
// IsRoot returns whether the process is running as root.
func (n *LocalNode) IsRoot() bool { return n.isRoot }
func (n *LocalNode) detectRoot() bool {
// Try "id -u" to check if running as root (uid 0).
ctx, cancel := context.WithTimeout(context.Background(), 5*secondDuration)
defer cancel()
out, _, code, err := n.Run(ctx, "id -u")
if err != nil || code != 0 {
return false
}
return strings.TrimSpace(out) == "0"
}
// secondDuration avoids importing time in the constant.
const secondDuration = 1_000_000_000 // time.Second
// Run executes a command locally via bash -c.
func (n *LocalNode) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
c := exec.CommandContext(ctx, "bash", "-c", cmd)
var outBuf, errBuf bytes.Buffer
c.Stdout = &outBuf
c.Stderr = &errBuf
runErr := c.Run()
if ctx.Err() != nil {
return outBuf.String(), errBuf.String(), -1, fmt.Errorf("command timed out: %w", ctx.Err())
}
if runErr != nil {
if exitErr, ok := runErr.(*exec.ExitError); ok {
return outBuf.String(), errBuf.String(), exitErr.ExitCode(), nil
}
return outBuf.String(), errBuf.String(), -1, runErr
}
return outBuf.String(), errBuf.String(), 0, nil
}
// RunRoot executes a command as root. If already root, runs directly.
// Otherwise uses sudo -n (non-interactive, fails if password required).
func (n *LocalNode) RunRoot(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
if n.isRoot {
return n.Run(ctx, cmd)
}
return n.Run(ctx, "sudo -n "+cmd)
}
// Upload copies a file from local source to a local destination path.
func (n *LocalNode) Upload(local, remote string) error {
// Ensure destination directory exists.
dir := filepath.Dir(remote)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
src, err := os.Open(local)
if err != nil {
return fmt.Errorf("open source %s: %w", local, err)
}
defer src.Close()
dst, err := os.OpenFile(remote, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return fmt.Errorf("create dest %s: %w", remote, err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return fmt.Errorf("copy %s → %s: %w", local, remote, err)
}
return nil
}
// Close is a no-op for LocalNode (no connection to close).
func (n *LocalNode) Close() {}