mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
CP8-1: HTTP REST API (create/delete/lookup/list/assign/servers), blockapi Go client with multi-master failover, 5 shell commands, HTML dashboard at /block/. CP8-2: RF=2/RF=3 multi-replica support -- ShipperGroup fan-out, distributed sync, health scoring, segment-based scrub, gated promotion (heartbeat freshness + WAL LSN + role checks), failover/rebuild for N>2 replicas. CP8-3: CSI snapshot + expansion -- CreateSnapshot/DeleteSnapshot/ListSnapshots RPCs, NodeExpandVolume with iSCSI rescan, snapshot ID helpers, 20 adversarial tests covering concurrent ops, edge cases, and error injection. CP8-4: Observability -- EngineMetrics atomic counters for flusher/group-commit/ WAL-shipper/scrub, 10 new Prometheus metrics, barrier_lag_lsn SLO gauge, failover/promotion/rebuild counters, request ID correlation in master gRPC logs, baseline regression framework with 7 hard-fail conditions. Total: 63 files, ~11.2K LOC, 160+ new tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
350 lines
10 KiB
Go
350 lines
10 KiB
Go
package csi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ISCSIUtil provides iSCSI initiator operations.
|
|
type ISCSIUtil interface {
|
|
Discovery(ctx context.Context, portal string) error
|
|
Login(ctx context.Context, iqn, portal string) error
|
|
Logout(ctx context.Context, iqn string) error
|
|
GetDeviceByIQN(ctx context.Context, iqn string) (string, error)
|
|
IsLoggedIn(ctx context.Context, iqn string) (bool, error)
|
|
RescanDevice(ctx context.Context, iqn string) error
|
|
}
|
|
|
|
// MountUtil provides filesystem mount operations.
|
|
type MountUtil interface {
|
|
FormatAndMount(ctx context.Context, device, target, fsType string) error
|
|
Mount(ctx context.Context, source, target, fsType string, readOnly bool) error
|
|
BindMount(ctx context.Context, source, target string, readOnly bool) error
|
|
Unmount(ctx context.Context, target string) error
|
|
IsFormatted(ctx context.Context, device string) (bool, error)
|
|
IsMounted(ctx context.Context, target string) (bool, error)
|
|
ResizeFS(ctx context.Context, devicePath, mountPath, fsType string) error
|
|
}
|
|
|
|
// realISCSIUtil uses iscsiadm CLI.
|
|
type realISCSIUtil struct{}
|
|
|
|
func (r *realISCSIUtil) Discovery(ctx context.Context, portal string) error {
|
|
cmd := exec.CommandContext(ctx, "iscsiadm", "-m", "discovery", "-t", "sendtargets", "-p", portal)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("iscsiadm discovery: %s: %w", string(out), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *realISCSIUtil) Login(ctx context.Context, iqn, portal string) error {
|
|
cmd := exec.CommandContext(ctx, "iscsiadm", "-m", "node", "-T", iqn, "-p", portal, "--login")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("iscsiadm login: %s: %w", string(out), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *realISCSIUtil) Logout(ctx context.Context, iqn string) error {
|
|
cmd := exec.CommandContext(ctx, "iscsiadm", "-m", "node", "-T", iqn, "--logout")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// Treat "not logged in" as success.
|
|
if strings.Contains(string(out), "No matching sessions") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("iscsiadm logout: %s: %w", string(out), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *realISCSIUtil) GetDeviceByIQN(ctx context.Context, iqn string) (string, error) {
|
|
// Poll for device to appear (iSCSI login is async).
|
|
deadline := time.After(10 * time.Second)
|
|
ticker := time.NewTicker(200 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case <-deadline:
|
|
return "", fmt.Errorf("timeout waiting for device for IQN %s", iqn)
|
|
case <-ticker.C:
|
|
// Look for block device symlinks under /dev/disk/by-path/
|
|
pattern := fmt.Sprintf("/dev/disk/by-path/*%s*", iqn)
|
|
matches, err := filepath.Glob(pattern)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, m := range matches {
|
|
// Skip partitions.
|
|
if strings.Contains(m, "-part") {
|
|
continue
|
|
}
|
|
// Resolve symlink to actual device.
|
|
dev, err := filepath.EvalSymlinks(m)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
return dev, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *realISCSIUtil) IsLoggedIn(ctx context.Context, iqn string) (bool, error) {
|
|
cmd := exec.CommandContext(ctx, "iscsiadm", "-m", "session")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// Exit code 21 = no sessions, not an error.
|
|
outStr := string(out)
|
|
if strings.Contains(outStr, "No active sessions") {
|
|
return false, nil
|
|
}
|
|
// Also handle exit code 21 directly (nsenter may suppress output).
|
|
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 21 {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("iscsiadm session: %s: %w", outStr, err)
|
|
}
|
|
return strings.Contains(string(out), iqn), nil
|
|
}
|
|
|
|
func (r *realISCSIUtil) RescanDevice(ctx context.Context, iqn string) error {
|
|
cmd := exec.CommandContext(ctx, "iscsiadm", "-m", "node", "-T", iqn, "--rescan")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("iscsiadm rescan: %s: %w", string(out), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// realMountUtil uses mount/umount/mkfs CLI.
|
|
type realMountUtil struct{}
|
|
|
|
func (r *realMountUtil) FormatAndMount(ctx context.Context, device, target, fsType string) error {
|
|
formatted, err := r.IsFormatted(ctx, device)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !formatted {
|
|
cmd := exec.CommandContext(ctx, "mkfs."+fsType, device)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("mkfs.%s: %s: %w", fsType, string(out), err)
|
|
}
|
|
}
|
|
return r.Mount(ctx, device, target, fsType, false)
|
|
}
|
|
|
|
func (r *realMountUtil) Mount(ctx context.Context, source, target, fsType string, readOnly bool) error {
|
|
args := []string{"-t", fsType}
|
|
if readOnly {
|
|
args = append(args, "-o", "ro")
|
|
}
|
|
args = append(args, source, target)
|
|
cmd := exec.CommandContext(ctx, "mount", args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("mount: %s: %w", string(out), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *realMountUtil) BindMount(ctx context.Context, source, target string, readOnly bool) error {
|
|
args := []string{"--bind", source, target}
|
|
cmd := exec.CommandContext(ctx, "mount", args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("bind mount: %s: %w", string(out), err)
|
|
}
|
|
if readOnly {
|
|
cmd = exec.CommandContext(ctx, "mount", "-o", "remount,bind,ro", target)
|
|
out, err = cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("remount ro: %s: %w", string(out), err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *realMountUtil) Unmount(ctx context.Context, target string) error {
|
|
cmd := exec.CommandContext(ctx, "umount", target)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// Treat "not mounted" as success.
|
|
if strings.Contains(string(out), "not mounted") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("umount: %s: %w", string(out), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *realMountUtil) IsFormatted(ctx context.Context, device string) (bool, error) {
|
|
cmd := exec.CommandContext(ctx, "blkid", "-p", device)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// Exit code 2 = no filesystem found.
|
|
if cmd.ProcessState != nil && cmd.ProcessState.ExitCode() == 2 {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("blkid: %s: %w", string(out), err)
|
|
}
|
|
return strings.Contains(string(out), "TYPE="), nil
|
|
}
|
|
|
|
func (r *realMountUtil) IsMounted(ctx context.Context, target string) (bool, error) {
|
|
cmd := exec.CommandContext(ctx, "mountpoint", "-q", target)
|
|
err := cmd.Run()
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
// Non-zero exit = not a mount point.
|
|
return false, nil
|
|
}
|
|
|
|
func (r *realMountUtil) ResizeFS(ctx context.Context, devicePath, mountPath, fsType string) error {
|
|
switch fsType {
|
|
case "ext4", "ext3", "ext2":
|
|
cmd := exec.CommandContext(ctx, "resize2fs", devicePath)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("resize2fs: %s: %w", string(out), err)
|
|
}
|
|
case "xfs":
|
|
cmd := exec.CommandContext(ctx, "xfs_growfs", mountPath)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("xfs_growfs: %s: %w", string(out), err)
|
|
}
|
|
default:
|
|
return fmt.Errorf("unsupported filesystem type for resize: %s", fsType)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mockISCSIUtil is a test double for ISCSIUtil.
|
|
type mockISCSIUtil struct {
|
|
discoveryErr error
|
|
loginErr error
|
|
logoutErr error
|
|
rescanErr error
|
|
getDeviceResult string
|
|
getDeviceErr error
|
|
loggedIn map[string]bool
|
|
calls []string
|
|
}
|
|
|
|
func newMockISCSIUtil() *mockISCSIUtil {
|
|
return &mockISCSIUtil{loggedIn: make(map[string]bool)}
|
|
}
|
|
|
|
func (m *mockISCSIUtil) Discovery(_ context.Context, portal string) error {
|
|
m.calls = append(m.calls, "discovery:"+portal)
|
|
return m.discoveryErr
|
|
}
|
|
|
|
func (m *mockISCSIUtil) Login(_ context.Context, iqn, portal string) error {
|
|
m.calls = append(m.calls, "login:"+iqn)
|
|
if m.loginErr != nil {
|
|
return m.loginErr
|
|
}
|
|
m.loggedIn[iqn] = true
|
|
return nil
|
|
}
|
|
|
|
func (m *mockISCSIUtil) Logout(_ context.Context, iqn string) error {
|
|
m.calls = append(m.calls, "logout:"+iqn)
|
|
if m.logoutErr != nil {
|
|
return m.logoutErr
|
|
}
|
|
delete(m.loggedIn, iqn)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockISCSIUtil) GetDeviceByIQN(_ context.Context, iqn string) (string, error) {
|
|
m.calls = append(m.calls, "getdevice:"+iqn)
|
|
return m.getDeviceResult, m.getDeviceErr
|
|
}
|
|
|
|
func (m *mockISCSIUtil) IsLoggedIn(_ context.Context, iqn string) (bool, error) {
|
|
return m.loggedIn[iqn], nil
|
|
}
|
|
|
|
func (m *mockISCSIUtil) RescanDevice(_ context.Context, iqn string) error {
|
|
m.calls = append(m.calls, "rescan:"+iqn)
|
|
return m.rescanErr
|
|
}
|
|
|
|
// mockMountUtil is a test double for MountUtil.
|
|
type mockMountUtil struct {
|
|
formatAndMountErr error
|
|
mountErr error
|
|
unmountErr error
|
|
resizeFSErr error
|
|
isFormattedResult bool
|
|
isMountedTargets map[string]bool
|
|
calls []string
|
|
}
|
|
|
|
func newMockMountUtil() *mockMountUtil {
|
|
return &mockMountUtil{isMountedTargets: make(map[string]bool)}
|
|
}
|
|
|
|
func (m *mockMountUtil) FormatAndMount(_ context.Context, device, target, fsType string) error {
|
|
m.calls = append(m.calls, "formatandmount:"+device+":"+target)
|
|
if m.formatAndMountErr != nil {
|
|
return m.formatAndMountErr
|
|
}
|
|
m.isMountedTargets[target] = true
|
|
return nil
|
|
}
|
|
|
|
func (m *mockMountUtil) Mount(_ context.Context, source, target, fsType string, readOnly bool) error {
|
|
m.calls = append(m.calls, "mount:"+source+":"+target)
|
|
if m.mountErr != nil {
|
|
return m.mountErr
|
|
}
|
|
m.isMountedTargets[target] = true
|
|
return nil
|
|
}
|
|
|
|
func (m *mockMountUtil) BindMount(_ context.Context, source, target string, readOnly bool) error {
|
|
m.calls = append(m.calls, "bindmount:"+source+":"+target)
|
|
if m.mountErr != nil {
|
|
return m.mountErr
|
|
}
|
|
m.isMountedTargets[target] = true
|
|
return nil
|
|
}
|
|
|
|
func (m *mockMountUtil) Unmount(_ context.Context, target string) error {
|
|
m.calls = append(m.calls, "unmount:"+target)
|
|
if m.unmountErr != nil {
|
|
return m.unmountErr
|
|
}
|
|
delete(m.isMountedTargets, target)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockMountUtil) IsFormatted(_ context.Context, device string) (bool, error) {
|
|
return m.isFormattedResult, nil
|
|
}
|
|
|
|
func (m *mockMountUtil) IsMounted(_ context.Context, target string) (bool, error) {
|
|
return m.isMountedTargets[target], nil
|
|
}
|
|
|
|
func (m *mockMountUtil) ResizeFS(_ context.Context, devicePath, mountPath, fsType string) error {
|
|
m.calls = append(m.calls, "resizefs:"+devicePath+":"+mountPath+":"+fsType)
|
|
return m.resizeFSErr
|
|
}
|