mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-22 07:06:51 +00:00
V2 runtime packages: - sw-block/runtime/masterv2: identity authority (desired state, heartbeat handling, promotion arbitration via SelectPromotionCandidate) - sw-block/runtime/volumev2: per-volume micro-cluster shell (node, orchestrator, control session, iSCSI frontend, takeover gate, failover session + driver, replica summary reconstruction) - sw-block/runtime/purev2: RF1 execution shell (engine + store + dispatcher + local boundary observations) - sw-block/runtime/protocolv2: three-channel separation (heartbeat/assignment/query + replica summary) V2 binaries: - sw-block/cmd/v2singleblock: single-node RF1 block server - sw-block/cmd/purev2rf1: minimal RF1 runtime binary Milestone capabilities: - RF1 write/read/sync with engine-driven mode projection - masterv2 ↔ volumev2 heartbeat convergence + assignment reissue - Promotion query with fresh CommittedLSN/WALHeadLSN evidence - Replica summary for bounded takeover reconstruction - Primary-loss reconstruction from peer summaries (fail-closed gate) - In-process failover driver with session observability - Local boundary observations feed engine (Committed/Durable/Checkpoint) Design docs: - v2-two-loop-protocol.md: identity vs data-control separation - v2-automata-ownership-map.md: event/command ownership split - v2-loop1-surface-draft.md: heartbeat/query/assignment field spec - v2-volumev2-single-node-mvp.md: target layering - v2-kernel-closure-review.md: per-volume micro-cluster principle - v2-pure-runtime-rf1-bootstrap.md, v2-capability-map.md, v2-proof-and-retest-pyramid.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
437 lines
11 KiB
Go
437 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/sw-block/runtime/masterv2"
|
|
"github.com/seaweedfs/seaweedfs/sw-block/runtime/volumev2"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/iscsi"
|
|
)
|
|
|
|
type smokeResult struct {
|
|
VolumeName string `json:"volume_name"`
|
|
Path string `json:"path"`
|
|
NodeID string `json:"node_id"`
|
|
Epoch uint64 `json:"epoch"`
|
|
Role string `json:"role"`
|
|
Mode string `json:"mode"`
|
|
Reason string `json:"reason"`
|
|
Readback string `json:"readback_hex"`
|
|
}
|
|
|
|
type restartSmokeResult struct {
|
|
VolumeName string `json:"volume_name"`
|
|
Path string `json:"path"`
|
|
NodeID string `json:"node_id"`
|
|
Epoch uint64 `json:"epoch"`
|
|
Role string `json:"role"`
|
|
Mode string `json:"mode"`
|
|
Reason string `json:"reason"`
|
|
InitialWrite string `json:"initial_write_hex"`
|
|
PostRestart string `json:"post_restart_hex"`
|
|
}
|
|
|
|
type iscsiSmokeResult struct {
|
|
VolumeName string `json:"volume_name"`
|
|
Path string `json:"path"`
|
|
NodeID string `json:"node_id"`
|
|
Epoch uint64 `json:"epoch"`
|
|
Role string `json:"role"`
|
|
Mode string `json:"mode"`
|
|
Reason string `json:"reason"`
|
|
IQN string `json:"iqn"`
|
|
Address string `json:"address"`
|
|
}
|
|
|
|
type commonFlags struct {
|
|
name string
|
|
path string
|
|
nodeID string
|
|
writeText string
|
|
sizeBytes uint64
|
|
blockSize uint
|
|
walSize uint64
|
|
}
|
|
|
|
type singleNodeEnv struct {
|
|
master *masterv2.Master
|
|
node *volumev2.Node
|
|
orchestrator *volumev2.Orchestrator
|
|
}
|
|
|
|
var commandOutput io.Writer = os.Stdout
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
|
|
switch os.Args[1] {
|
|
case "smoke":
|
|
if err := runSmoke(os.Args[2:]); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
case "restart-smoke":
|
|
if err := runRestartSmoke(os.Args[2:]); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
case "iscsi-smoke":
|
|
if err := runISCSISmoke(os.Args[2:]); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
default:
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
func runSmoke(args []string) error {
|
|
cfg, err := parseCommonFlags("smoke", args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cfg.path == "" {
|
|
return fmt.Errorf("smoke: --path is required")
|
|
}
|
|
|
|
env, err := bootstrapSingleNode(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer env.close()
|
|
|
|
payload := paddedPayload([]byte(cfg.writeText), uint32(cfg.blockSize))
|
|
if err := env.node.WriteLBA(cfg.name, 0, payload); err != nil {
|
|
return err
|
|
}
|
|
if err := env.node.SyncCache(cfg.name); err != nil {
|
|
return err
|
|
}
|
|
readBack, err := env.node.ReadLBA(cfg.name, 0, uint32(len(payload)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
snap, err := env.node.Snapshot(cfg.name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
result := smokeResult{
|
|
VolumeName: cfg.name,
|
|
Path: cfg.path,
|
|
NodeID: cfg.nodeID,
|
|
Epoch: snap.Status.Epoch,
|
|
Role: snap.Status.Role.String(),
|
|
Readback: hex.EncodeToString(readBack[:len(payload)]),
|
|
}
|
|
if snap.HasProjection {
|
|
result.Mode = string(snap.Projection.Mode.Name)
|
|
result.Reason = snap.Projection.Publication.Reason
|
|
}
|
|
return printJSON(result)
|
|
}
|
|
|
|
func runRestartSmoke(args []string) error {
|
|
cfg, err := parseCommonFlags("restart-smoke", args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cfg.path == "" {
|
|
return fmt.Errorf("restart-smoke: --path is required")
|
|
}
|
|
|
|
payload := paddedPayload([]byte(cfg.writeText), uint32(cfg.blockSize))
|
|
master := masterv2.New(masterv2.Config{LeaseTTL: 30 * time.Second})
|
|
if err := declarePrimary(master, cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
func() {
|
|
node, nodeErr := volumev2.New(volumev2.Config{NodeID: cfg.nodeID})
|
|
if nodeErr != nil {
|
|
err = nodeErr
|
|
return
|
|
}
|
|
defer node.Close()
|
|
session, nodeErr := volumev2.NewInProcessSession(master)
|
|
if nodeErr != nil {
|
|
err = nodeErr
|
|
return
|
|
}
|
|
orchestrator, nodeErr := volumev2.NewOrchestrator(node, session)
|
|
if nodeErr != nil {
|
|
err = nodeErr
|
|
return
|
|
}
|
|
if nodeErr = syncTwice(orchestrator); nodeErr != nil {
|
|
err = nodeErr
|
|
return
|
|
}
|
|
if nodeErr = node.WriteLBA(cfg.name, 0, payload); nodeErr != nil {
|
|
err = nodeErr
|
|
return
|
|
}
|
|
if nodeErr = node.SyncCache(cfg.name); nodeErr != nil {
|
|
err = nodeErr
|
|
return
|
|
}
|
|
}()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
node, err := volumev2.New(volumev2.Config{NodeID: cfg.nodeID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer node.Close()
|
|
session, err := volumev2.NewInProcessSession(master)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
orchestrator, err := volumev2.NewOrchestrator(node, session)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := syncTwice(orchestrator); err != nil {
|
|
return err
|
|
}
|
|
readBack, err := node.ReadLBA(cfg.name, 0, uint32(len(payload)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
snap, err := node.Snapshot(cfg.name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
result := restartSmokeResult{
|
|
VolumeName: cfg.name,
|
|
Path: cfg.path,
|
|
NodeID: cfg.nodeID,
|
|
Epoch: snap.Status.Epoch,
|
|
Role: snap.Status.Role.String(),
|
|
InitialWrite: hex.EncodeToString(payload),
|
|
PostRestart: hex.EncodeToString(readBack[:len(payload)]),
|
|
}
|
|
if snap.HasProjection {
|
|
result.Mode = string(snap.Projection.Mode.Name)
|
|
result.Reason = snap.Projection.Publication.Reason
|
|
}
|
|
return printJSON(result)
|
|
}
|
|
|
|
func runISCSISmoke(args []string) error {
|
|
fs := flag.NewFlagSet("iscsi-smoke", flag.ContinueOnError)
|
|
name := fs.String("name", "single-node-vol", "logical volume name")
|
|
path := fs.String("path", "", "block volume file path")
|
|
nodeID := fs.String("node", "node-a", "volumev2 node id")
|
|
writeText := fs.String("write-text", "v2-single-node-smoke", "payload written at LBA 0")
|
|
sizeBytes := fs.Uint64("size-bytes", 1*1024*1024, "logical volume size in bytes")
|
|
blockSize := fs.Uint("block-size", 4096, "block size in bytes")
|
|
walSize := fs.Uint64("wal-size", 256*1024, "wal size in bytes")
|
|
listenAddr := fs.String("listen-addr", "127.0.0.1:0", "iSCSI target listen address")
|
|
iqn := fs.String("iqn", "", "target iqn")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
cfg := commonFlags{
|
|
name: *name,
|
|
path: *path,
|
|
nodeID: *nodeID,
|
|
writeText: *writeText,
|
|
sizeBytes: *sizeBytes,
|
|
blockSize: *blockSize,
|
|
walSize: *walSize,
|
|
}
|
|
if cfg.path == "" {
|
|
return fmt.Errorf("iscsi-smoke: --path is required")
|
|
}
|
|
|
|
env, err := bootstrapSingleNode(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer env.close()
|
|
|
|
export, err := env.node.ExportISCSI(cfg.name, *listenAddr, *iqn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer export.Close()
|
|
|
|
if err := performISCSILogin(export.Address(), export.IQN()); err != nil {
|
|
return err
|
|
}
|
|
snap, err := env.node.Snapshot(cfg.name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
result := iscsiSmokeResult{
|
|
VolumeName: cfg.name,
|
|
Path: cfg.path,
|
|
NodeID: cfg.nodeID,
|
|
Epoch: snap.Status.Epoch,
|
|
Role: snap.Status.Role.String(),
|
|
IQN: export.IQN(),
|
|
Address: export.Address(),
|
|
}
|
|
if snap.HasProjection {
|
|
result.Mode = string(snap.Projection.Mode.Name)
|
|
result.Reason = snap.Projection.Publication.Reason
|
|
}
|
|
return printJSON(result)
|
|
}
|
|
|
|
func parseCommonFlags(name string, args []string) (commonFlags, error) {
|
|
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
|
cfg := commonFlags{}
|
|
namePtr := fs.String("name", "single-node-vol", "logical volume name")
|
|
pathPtr := fs.String("path", "", "block volume file path")
|
|
nodePtr := fs.String("node", "node-a", "volumev2 node id")
|
|
writePtr := fs.String("write-text", "v2-single-node-smoke", "payload written at LBA 0")
|
|
sizePtr := fs.Uint64("size-bytes", 1*1024*1024, "logical volume size in bytes")
|
|
blockPtr := fs.Uint("block-size", 4096, "block size in bytes")
|
|
walPtr := fs.Uint64("wal-size", 256*1024, "wal size in bytes")
|
|
if err := fs.Parse(args); err != nil {
|
|
return cfg, err
|
|
}
|
|
cfg.name = *namePtr
|
|
cfg.path = *pathPtr
|
|
cfg.nodeID = *nodePtr
|
|
cfg.writeText = *writePtr
|
|
cfg.sizeBytes = *sizePtr
|
|
cfg.blockSize = *blockPtr
|
|
cfg.walSize = *walPtr
|
|
return cfg, nil
|
|
}
|
|
|
|
func bootstrapSingleNode(cfg commonFlags) (*singleNodeEnv, error) {
|
|
master := masterv2.New(masterv2.Config{LeaseTTL: 30 * time.Second})
|
|
if err := declarePrimary(master, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
node, err := volumev2.New(volumev2.Config{NodeID: cfg.nodeID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
session, err := volumev2.NewInProcessSession(master)
|
|
if err != nil {
|
|
node.Close()
|
|
return nil, err
|
|
}
|
|
orchestrator, err := volumev2.NewOrchestrator(node, session)
|
|
if err != nil {
|
|
node.Close()
|
|
return nil, err
|
|
}
|
|
if err := syncTwice(orchestrator); err != nil {
|
|
node.Close()
|
|
return nil, err
|
|
}
|
|
return &singleNodeEnv{
|
|
master: master,
|
|
node: node,
|
|
orchestrator: orchestrator,
|
|
}, nil
|
|
}
|
|
|
|
func declarePrimary(master *masterv2.Master, cfg commonFlags) error {
|
|
return master.DeclarePrimary(masterv2.VolumeSpec{
|
|
Name: cfg.name,
|
|
Path: cfg.path,
|
|
PrimaryNodeID: cfg.nodeID,
|
|
CreateOptions: blockvol.CreateOptions{
|
|
VolumeSize: cfg.sizeBytes,
|
|
BlockSize: uint32(cfg.blockSize),
|
|
WALSize: cfg.walSize,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (env *singleNodeEnv) close() {
|
|
if env == nil || env.node == nil {
|
|
return
|
|
}
|
|
env.node.Close()
|
|
}
|
|
|
|
func syncTwice(orchestrator *volumev2.Orchestrator) error {
|
|
if err := orchestrator.SyncOnce(); err != nil {
|
|
return err
|
|
}
|
|
return orchestrator.SyncOnce()
|
|
}
|
|
|
|
func performISCSILogin(addr, targetIQN string) error {
|
|
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
|
if err != nil {
|
|
return fmt.Errorf("dial iscsi target %s: %w", addr, err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
params := iscsi.NewParams()
|
|
params.Set("InitiatorName", "iqn.2026-04.com.seaweedfs:cli.initiator")
|
|
params.Set("TargetName", targetIQN)
|
|
params.Set("SessionType", "Normal")
|
|
|
|
loginReq := &iscsi.PDU{}
|
|
loginReq.SetOpcode(iscsi.OpLoginReq)
|
|
loginReq.SetLoginStages(iscsi.StageSecurityNeg, iscsi.StageFullFeature)
|
|
loginReq.SetLoginTransit(true)
|
|
loginReq.SetISID([6]byte{0x00, 0x02, 0x3D, 0x00, 0x00, 0x02})
|
|
loginReq.SetCmdSN(1)
|
|
loginReq.DataSegment = params.Encode()
|
|
|
|
if err := iscsi.WritePDU(conn, loginReq); err != nil {
|
|
return fmt.Errorf("write iscsi login: %w", err)
|
|
}
|
|
resp, err := iscsi.ReadPDU(conn)
|
|
if err != nil {
|
|
return fmt.Errorf("read iscsi login: %w", err)
|
|
}
|
|
if resp.LoginStatusClass() != iscsi.LoginStatusSuccess {
|
|
return fmt.Errorf("iscsi login failed: %d/%d", resp.LoginStatusClass(), resp.LoginStatusDetail())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func paddedPayload(in []byte, blockSize uint32) []byte {
|
|
size := int(blockSize)
|
|
if size <= 0 {
|
|
size = len(in)
|
|
}
|
|
if size < len(in) {
|
|
size = len(in)
|
|
}
|
|
out := make([]byte, size)
|
|
copy(out, in)
|
|
return out
|
|
}
|
|
|
|
func printJSON(v any) error {
|
|
enc := json.NewEncoder(commandOutput)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(v)
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprintln(os.Stderr, "usage:")
|
|
fmt.Fprintln(os.Stderr, " v2singleblock smoke --path <file> [--name N --node N --write-text TEXT]")
|
|
fmt.Fprintln(os.Stderr, " v2singleblock restart-smoke --path <file> [--name N --node N --write-text TEXT]")
|
|
fmt.Fprintln(os.Stderr, " v2singleblock iscsi-smoke --path <file> [--name N --node N --iqn IQN]")
|
|
}
|