mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-24 08:54:28 +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>
380 lines
9.4 KiB
Go
380 lines
9.4 KiB
Go
package infra
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// Node represents an SSH-accessible (or local WSL2/native) machine.
|
|
type Node struct {
|
|
Host string
|
|
User string
|
|
KeyFile string
|
|
IsLocal bool // WSL2 mode: use exec.CommandContext via wsl instead of SSH
|
|
IsNative bool // Native local mode: use bash -c directly (for Linux agents)
|
|
|
|
mu sync.Mutex
|
|
client *ssh.Client
|
|
}
|
|
|
|
// Connect establishes the SSH connection (no-op for local/native mode).
|
|
func (n *Node) Connect() error {
|
|
if n.IsLocal || n.IsNative {
|
|
return nil
|
|
}
|
|
|
|
key, err := os.ReadFile(n.KeyFile)
|
|
if err != nil {
|
|
return fmt.Errorf("read SSH key %s: %w", n.KeyFile, err)
|
|
}
|
|
signer, err := ssh.ParsePrivateKey(key)
|
|
if err != nil {
|
|
return fmt.Errorf("parse SSH key: %w", err)
|
|
}
|
|
|
|
config := &ssh.ClientConfig{
|
|
User: n.User,
|
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
|
|
addr := n.Host
|
|
if !strings.Contains(addr, ":") {
|
|
addr += ":22"
|
|
}
|
|
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
n.client, err = ssh.Dial("tcp", addr, config)
|
|
if err != nil {
|
|
return fmt.Errorf("SSH dial %s: %w", addr, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Run executes a command and returns stdout, stderr, exit code.
|
|
func (n *Node) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
|
|
if n.IsNative {
|
|
return n.runNative(ctx, cmd)
|
|
}
|
|
if n.IsLocal {
|
|
return n.runLocal(ctx, cmd)
|
|
}
|
|
return n.runSSH(ctx, cmd)
|
|
}
|
|
|
|
func (n *Node) runNative(ctx context.Context, cmd string) (string, string, int, error) {
|
|
c := exec.CommandContext(ctx, "bash", "-c", cmd)
|
|
var outBuf, errBuf bytes.Buffer
|
|
c.Stdout = &outBuf
|
|
c.Stderr = &errBuf
|
|
|
|
err := c.Run()
|
|
if ctx.Err() != nil {
|
|
return outBuf.String(), errBuf.String(), -1, fmt.Errorf("command timed out: %w", ctx.Err())
|
|
}
|
|
if err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
return outBuf.String(), errBuf.String(), exitErr.ExitCode(), nil
|
|
}
|
|
return outBuf.String(), errBuf.String(), -1, err
|
|
}
|
|
return outBuf.String(), errBuf.String(), 0, nil
|
|
}
|
|
|
|
func (n *Node) runLocal(ctx context.Context, cmd string) (string, string, int, error) {
|
|
var c *exec.Cmd
|
|
if runtime.GOOS == "windows" {
|
|
c = exec.CommandContext(ctx, "wsl", "-e", "bash", "-c", cmd)
|
|
} else {
|
|
c = exec.CommandContext(ctx, "bash", "-c", cmd)
|
|
}
|
|
var outBuf, errBuf bytes.Buffer
|
|
c.Stdout = &outBuf
|
|
c.Stderr = &errBuf
|
|
|
|
err := c.Run()
|
|
if ctx.Err() != nil {
|
|
return outBuf.String(), errBuf.String(), -1, fmt.Errorf("command timed out: %w", ctx.Err())
|
|
}
|
|
if err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
return outBuf.String(), errBuf.String(), exitErr.ExitCode(), nil
|
|
}
|
|
return outBuf.String(), errBuf.String(), -1, err
|
|
}
|
|
return outBuf.String(), errBuf.String(), 0, nil
|
|
}
|
|
|
|
func (n *Node) runSSH(ctx context.Context, cmd string) (string, string, int, error) {
|
|
n.mu.Lock()
|
|
if n.client == nil {
|
|
n.mu.Unlock()
|
|
return "", "", -1, fmt.Errorf("SSH not connected")
|
|
}
|
|
session, err := n.client.NewSession()
|
|
n.mu.Unlock()
|
|
if err != nil {
|
|
return "", "", -1, fmt.Errorf("new SSH session: %w", err)
|
|
}
|
|
defer session.Close()
|
|
|
|
var outBuf, errBuf bytes.Buffer
|
|
session.Stdout = &outBuf
|
|
session.Stderr = &errBuf
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- session.Run(cmd) }()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = session.Signal(ssh.SIGKILL)
|
|
return outBuf.String(), errBuf.String(), -1, fmt.Errorf("command timed out: %w", ctx.Err())
|
|
case err := <-done:
|
|
if err != nil {
|
|
if exitErr, ok := err.(*ssh.ExitError); ok {
|
|
return outBuf.String(), errBuf.String(), exitErr.ExitStatus(), nil
|
|
}
|
|
return outBuf.String(), errBuf.String(), -1, err
|
|
}
|
|
return outBuf.String(), errBuf.String(), 0, nil
|
|
}
|
|
}
|
|
|
|
// RunRoot executes a command with sudo -n (non-interactive).
|
|
func (n *Node) RunRoot(ctx context.Context, cmd string) (string, string, int, error) {
|
|
return n.Run(ctx, "sudo -n "+cmd)
|
|
}
|
|
|
|
// Upload copies a local file to the remote node via SCP.
|
|
func (n *Node) Upload(local, remote string) error {
|
|
if n.IsNative {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
_, stderr, code, err := n.Run(ctx, fmt.Sprintf("cp %s %s && chmod +x %s", local, remote, remote))
|
|
if err != nil || code != 0 {
|
|
return fmt.Errorf("native upload: code=%d stderr=%s err=%v", code, stderr, err)
|
|
}
|
|
return nil
|
|
}
|
|
if n.IsLocal {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
src := local
|
|
if runtime.GOOS == "windows" {
|
|
src = ToWSLPath(local)
|
|
}
|
|
_, stderr, code, err := n.Run(ctx, fmt.Sprintf("cp %s %s && chmod +x %s", src, remote, remote))
|
|
if err != nil || code != 0 {
|
|
return fmt.Errorf("local upload: code=%d stderr=%s err=%v", code, stderr, err)
|
|
}
|
|
return nil
|
|
}
|
|
return n.scpUpload(local, remote)
|
|
}
|
|
|
|
func (n *Node) scpUpload(local, remote string) error {
|
|
data, err := os.ReadFile(local)
|
|
if err != nil {
|
|
return fmt.Errorf("read local file %s: %w", local, err)
|
|
}
|
|
|
|
n.mu.Lock()
|
|
if n.client == nil {
|
|
n.mu.Unlock()
|
|
return fmt.Errorf("SSH not connected")
|
|
}
|
|
session, err := n.client.NewSession()
|
|
n.mu.Unlock()
|
|
if err != nil {
|
|
return fmt.Errorf("new SSH session: %w", err)
|
|
}
|
|
defer session.Close()
|
|
|
|
// Use cat+chmod over stdin — simpler and more reliable than SCP protocol.
|
|
w, err := session.StdinPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("stdin pipe: %w", err)
|
|
}
|
|
|
|
go func() {
|
|
w.Write(data)
|
|
w.Close()
|
|
}()
|
|
|
|
cmd := fmt.Sprintf("cat > %s && chmod +x %s", remote, remote)
|
|
if err := session.Run(cmd); err != nil {
|
|
return fmt.Errorf("upload run: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Download copies a remote file to local via SCP.
|
|
func (n *Node) Download(remote, local string) error {
|
|
if n.IsNative {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
_, stderr, code, err := n.Run(ctx, fmt.Sprintf("cp %s %s", remote, local))
|
|
if err != nil || code != 0 {
|
|
return fmt.Errorf("native download: code=%d stderr=%s err=%v", code, stderr, err)
|
|
}
|
|
return nil
|
|
}
|
|
if n.IsLocal {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
dst := local
|
|
if runtime.GOOS == "windows" {
|
|
dst = ToWSLPath(local)
|
|
}
|
|
_, stderr, code, err := n.Run(ctx, fmt.Sprintf("cp %s %s", remote, dst))
|
|
if err != nil || code != 0 {
|
|
return fmt.Errorf("local download: code=%d stderr=%s err=%v", code, stderr, err)
|
|
}
|
|
return nil
|
|
}
|
|
return n.scpDownload(remote, local)
|
|
}
|
|
|
|
func (n *Node) scpDownload(remote, local string) error {
|
|
n.mu.Lock()
|
|
if n.client == nil {
|
|
n.mu.Unlock()
|
|
return fmt.Errorf("SSH not connected")
|
|
}
|
|
session, err := n.client.NewSession()
|
|
n.mu.Unlock()
|
|
if err != nil {
|
|
return fmt.Errorf("new SSH session: %w", err)
|
|
}
|
|
defer session.Close()
|
|
|
|
var buf bytes.Buffer
|
|
session.Stdout = &buf
|
|
if err := session.Run(fmt.Sprintf("cat %s", remote)); err != nil {
|
|
return fmt.Errorf("read remote %s: %w", remote, err)
|
|
}
|
|
return os.WriteFile(local, buf.Bytes(), 0644)
|
|
}
|
|
|
|
// Kill sends SIGKILL to a process by PID.
|
|
func (n *Node) Kill(pid int) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_, _, _, err := n.RunRoot(ctx, fmt.Sprintf("kill -9 %d", pid))
|
|
return err
|
|
}
|
|
|
|
// HasCommand checks if a command is available on the node.
|
|
func (n *Node) HasCommand(cmd string) bool {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_, _, code, err := n.Run(ctx, fmt.Sprintf("which %s", cmd))
|
|
return err == nil && code == 0
|
|
}
|
|
|
|
// Close closes the SSH connection.
|
|
func (n *Node) Close() {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
if n.client != nil {
|
|
n.client.Close()
|
|
n.client = nil
|
|
}
|
|
}
|
|
|
|
// DialTCP opens a direct TCP connection through the SSH tunnel.
|
|
func (n *Node) DialTCP(addr string) (net.Conn, error) {
|
|
if n.IsLocal || n.IsNative {
|
|
return net.DialTimeout("tcp", addr, 5*time.Second)
|
|
}
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
if n.client == nil {
|
|
return nil, fmt.Errorf("SSH not connected")
|
|
}
|
|
return n.client.Dial("tcp", addr)
|
|
}
|
|
|
|
// StreamRun executes a command and streams stdout to the writer.
|
|
func (n *Node) StreamRun(ctx context.Context, cmd string, w io.Writer) error {
|
|
if n.IsNative {
|
|
c := exec.CommandContext(ctx, "bash", "-c", cmd)
|
|
c.Stdout = w
|
|
c.Stderr = w
|
|
return c.Run()
|
|
}
|
|
if n.IsLocal {
|
|
var c *exec.Cmd
|
|
if runtime.GOOS == "windows" {
|
|
c = exec.CommandContext(ctx, "wsl", "-e", "bash", "-c", cmd)
|
|
} else {
|
|
c = exec.CommandContext(ctx, "bash", "-c", cmd)
|
|
}
|
|
c.Stdout = w
|
|
c.Stderr = w
|
|
return c.Run()
|
|
}
|
|
|
|
n.mu.Lock()
|
|
if n.client == nil {
|
|
n.mu.Unlock()
|
|
return fmt.Errorf("SSH not connected")
|
|
}
|
|
session, err := n.client.NewSession()
|
|
n.mu.Unlock()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer session.Close()
|
|
|
|
session.Stdout = w
|
|
session.Stderr = w
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- session.Run(cmd) }()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = session.Signal(ssh.SIGKILL)
|
|
return ctx.Err()
|
|
case err := <-done:
|
|
return err
|
|
}
|
|
}
|
|
|
|
// ToWSLPath converts a Windows path to a WSL path.
|
|
func ToWSLPath(winPath string) string {
|
|
p := strings.ReplaceAll(winPath, "\\", "/")
|
|
if len(p) >= 2 && p[1] == ':' {
|
|
drive := strings.ToLower(string(p[0]))
|
|
p = "/mnt/" + drive + p[2:]
|
|
}
|
|
return p
|
|
}
|
|
|
|
func remoteName(path string) string {
|
|
parts := strings.Split(path, "/")
|
|
return parts[len(parts)-1]
|
|
}
|
|
|
|
func remoteDir(path string) string {
|
|
idx := strings.LastIndex(path, "/")
|
|
if idx < 0 {
|
|
return "."
|
|
}
|
|
return path[:idx]
|
|
}
|