From 9c532af3909a33cbe23a1a02d405129a4110a9d6 Mon Sep 17 00:00:00 2001 From: Guillaume Carre Date: Thu, 25 Jun 2026 13:24:55 +0200 Subject: [PATCH] feat(storcli2): physical drive blinker Implement ports.Blinker on the storcli2/perccli2 adapter: - StartBlink / StopBlink issue "/cx/ex/sx start locate" / "stop locate" (same grammar as storcli), choosing the enclosure or no-enclosure selector form from the drive's parsed slot, and surface the in-JSON failure payload via storcli2.Decode. A single implementation serves both binaries; the runner is injected. Issue: ARTESCA-17650 --- pkg/implementation/blinker/storcli2.go | 96 +++++++++++++++++ pkg/implementation/blinker/storcli2_test.go | 111 ++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 pkg/implementation/blinker/storcli2.go create mode 100644 pkg/implementation/blinker/storcli2_test.go diff --git a/pkg/implementation/blinker/storcli2.go b/pkg/implementation/blinker/storcli2.go new file mode 100644 index 0000000..ea7f3be --- /dev/null +++ b/pkg/implementation/blinker/storcli2.go @@ -0,0 +1,96 @@ +package blinker + +import ( + "fmt" + + "github.com/pkg/errors" + + "github.com/scality/raidmgmt/pkg/domain/entities/physicaldrive" + "github.com/scality/raidmgmt/pkg/domain/ports" + "github.com/scality/raidmgmt/pkg/implementation/commandrunner" + "github.com/scality/raidmgmt/pkg/implementation/storcli2" +) + +const ( + // storcli2CmdStart and storcli2CmdStop begin and end a drive locate. + storcli2CmdStart = "start" + storcli2CmdStop = "stop" + // storcli2CmdLocate is the locate (blink) operation token. + storcli2CmdLocate = "locate" + // storcli2EnclosureSelector and storcli2NoEnclosureSelector address a single + // drive, with or without an enclosure component. + storcli2EnclosureSelector = "/c%d/e%s/s%s" + storcli2NoEnclosureSelector = "/c%d/s%s" +) + +// StorCLI2 blinks physical drives through a storcli2/perccli2 command runner. A +// single implementation serves both binaries; the concrete runner is injected +// at construction time. +type StorCLI2 struct { + runner commandrunner.CommandRunner +} + +var _ ports.Blinker = &StorCLI2{} + +// NewStorCLI2 returns a blinker backed by the given storcli2 / perccli2 command +// runner. +func NewStorCLI2(runner commandrunner.CommandRunner) *StorCLI2 { + return &StorCLI2{ + runner: runner, + } +} + +// StartBlink starts locating (blinking) a physical drive. +func (s *StorCLI2) StartBlink(metadata *physicaldrive.Metadata) error { + if err := s.locate(metadata, storcli2CmdStart); err != nil { + return errors.Wrap(err, "failed to start blinking physical drive") + } + + return nil +} + +// StopBlink stops locating (blinking) a physical drive. +func (s *StorCLI2) StopBlink(metadata *physicaldrive.Metadata) error { + if err := s.locate(metadata, storcli2CmdStop); err != nil { + return errors.Wrap(err, "failed to stop blinking physical drive") + } + + return nil +} + +// locate runs " locate" on a drive selector and surfaces the +// in-JSON failure that storcli2 may report regardless of its exit code. +func (s *StorCLI2) locate(metadata *physicaldrive.Metadata, action string) error { + selector, err := storcli2SelectorPD(metadata) + if err != nil { + return errors.Wrap(err, "failed to build drive selector") + } + + output, err := s.runner.Run([]string{selector, action, storcli2CmdLocate}) + if err != nil { + return errors.Wrapf(err, "failed to run %s locate command", action) + } + + if _, err := storcli2.Decode(output); err != nil { + return errors.Wrapf(err, "%s locate command failed", action) + } + + return nil +} + +// storcli2SelectorPD builds the storcli2 selector for a drive, choosing the +// enclosure or no-enclosure form from its parsed slot. +func storcli2SelectorPD(metadata *physicaldrive.Metadata) (string, error) { + slot, err := physicaldrive.ParseSlot(metadata.ID) + if err != nil { + return "", errors.Wrapf(err, "failed to parse slot %s", metadata.ID) + } + + if slot.Enclosure != "" { + return fmt.Sprintf( + storcli2EnclosureSelector, metadata.CtrlMetadata.ID, slot.Enclosure, slot.Bay, + ), nil + } + + return fmt.Sprintf(storcli2NoEnclosureSelector, metadata.CtrlMetadata.ID, slot.Bay), nil +} diff --git a/pkg/implementation/blinker/storcli2_test.go b/pkg/implementation/blinker/storcli2_test.go new file mode 100644 index 0000000..15c2c41 --- /dev/null +++ b/pkg/implementation/blinker/storcli2_test.go @@ -0,0 +1,111 @@ +package blinker_test + +import ( + "os" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/scality/raidmgmt/pkg/domain/entities/physicaldrive" + "github.com/scality/raidmgmt/pkg/domain/entities/raidcontroller" + "github.com/scality/raidmgmt/pkg/implementation/blinker" +) + +type MockCommandRunner struct { + mock.Mock +} + +func (m *MockCommandRunner) Run(args []string) ([]byte, error) { + arguments := m.Called(args) + + return arguments.Get(0).([]byte), arguments.Error(1) +} + +// storcli2Fixture reads a storcli2 JSON fixture from the package testdata. +func storcli2Fixture(t *testing.T, name string) []byte { + t.Helper() + + data, err := os.ReadFile("testdata/storcli2/" + name) + require.NoError(t, err) + + return data +} + +// storcli2PDMetadata builds physical-drive metadata with the given EID:Slt id. +func storcli2PDMetadata(id string) *physicaldrive.Metadata { + return &physicaldrive.Metadata{ + CtrlMetadata: &raidcontroller.Metadata{ID: 0}, + ID: id, + } +} + +// TestStorCLI2Blink covers the start/stop locate happy paths, including the +// enclosure and no-enclosure selector forms. +func TestStorCLI2Blink(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + id string + selector string + action string + fixture string + start bool + }{ + {name: "start with enclosure", id: "252:0", selector: "/c0/e252/s0", action: "start", fixture: "start.json", start: true}, + {name: "stop with enclosure", id: "252:0", selector: "/c0/e252/s0", action: "stop", fixture: "stop.json", start: false}, + {name: "start without enclosure", id: "5", selector: "/c0/s5", action: "start", fixture: "start.json", start: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockRunner := new(MockCommandRunner) + mockRunner.On("Run", []string{tt.selector, tt.action, "locate"}). + Return(storcli2Fixture(t, tt.fixture), nil) + + b := blinker.NewStorCLI2(mockRunner) + + var err error + if tt.start { + err = b.StartBlink(storcli2PDMetadata(tt.id)) + } else { + err = b.StopBlink(storcli2PDMetadata(tt.id)) + } + + require.NoError(t, err) + mockRunner.AssertExpectations(t) + }) + } +} + +// TestStorCLI2BlinkCommandError pins that a runner failure is surfaced. +func TestStorCLI2BlinkCommandError(t *testing.T) { + t.Parallel() + + mockRunner := new(MockCommandRunner) + mockRunner.On("Run", []string{"/c0/e252/s0", "start", "locate"}). + Return([]byte(nil), errors.New("boom")) + + b := blinker.NewStorCLI2(mockRunner) + + err := b.StartBlink(storcli2PDMetadata("252:0")) + require.Error(t, err) +} + +// TestStorCLI2BlinkInvalidSlot pins that an unparseable drive id is rejected +// before any command is run. +func TestStorCLI2BlinkInvalidSlot(t *testing.T) { + t.Parallel() + + mockRunner := new(MockCommandRunner) + + b := blinker.NewStorCLI2(mockRunner) + + err := b.StopBlink(storcli2PDMetadata("")) + require.Error(t, err) + mockRunner.AssertNotCalled(t, "Run") +}