Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions pkg/implementation/logicalvolumemanager/storcli2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package logicalvolumemanager

import (
"fmt"

"github.com/pkg/errors"

"github.com/scality/raidmgmt/pkg/domain/entities/logicalvolume"
"github.com/scality/raidmgmt/pkg/domain/ports"
"github.com/scality/raidmgmt/pkg/implementation/commandrunner"
"github.com/scality/raidmgmt/pkg/implementation/storcli2"
)

const (
// storcli2CmdSet is the storcli2 "set" command token.
storcli2CmdSet = "set"
// storcli2VolumeSelector addresses a single virtual drive by its number.
storcli2VolumeSelector = "/c%d/v%s"
// storcli2RdCacheFlag and storcli2WrCacheFlag are the read/write cache flags
// of the "set" command. storcli2 has no IO policy flag.
storcli2RdCacheFlag = "rdcache="
storcli2WrCacheFlag = "wrcache="
)

// StorCLI2 manages logical volumes through a storcli2/perccli2 command runner.
// A single implementation serves both binaries; the concrete runner is injected
// at construction time. The current state is read through an injected
// LogicalVolumesGetter so setters only emit the flags that actually change.
type StorCLI2 struct {
ports.LogicalVolumesGetter

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exported field StorCLI2 shadows the struct type name, which makes usage sites (e.g. s.StorCLI2.Run(...)) ambiguous to read. The existing physicaldrivegetter.StorCLI2 uses an unexported runner field for the same role — matching that convention would be more consistent and keep the command runner out of the public API surface.

Suggested change
runner commandrunner.CommandRunner

— Claude Code

StorCLI2 commandrunner.CommandRunner
}

var _ ports.LVCacheSetter = &StorCLI2{}

// NewStorCLI2 returns a logical-volume manager backed by the given storcli2 /
// perccli2 command runner and logical-volume getter.
func NewStorCLI2(
runner commandrunner.CommandRunner,
logicalVolumesGetter ports.LogicalVolumesGetter,
) *StorCLI2 {
return &StorCLI2{
LogicalVolumesGetter: logicalVolumesGetter,
StorCLI2: runner,
}
}

// SetLVCacheOptions applies the desired cache options to a logical volume,
// emitting only the flags that differ from the current state. storcli2 rejects
// the combined cache syntax and dropped the IO policy, so the read and write
// policies are set through two independent "set" commands and the IO policy is
// ignored.
func (s *StorCLI2) SetLVCacheOptions(
metadata *logicalvolume.Metadata,
desired *logicalvolume.CacheOptions,
) error {
current, err := s.LogicalVolume(metadata)
if err != nil {
return errors.Wrapf(err, "failed to get logical volume %s", metadata.ID)
}

options, err := storcli2CacheOptions(current.CacheOptions, desired)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

current.CacheOptions is a pointer field on LogicalVolume. If the getter ever returns a volume without cache metadata (e.g. a volume type that doesn't support caching), this will nil-pointer panic. A guard before the call would make the setter robust against upstream getter changes.

Suggested change
options, err := storcli2CacheOptions(current.CacheOptions, desired)
if current.CacheOptions == nil {
return errors.Errorf("logical volume %s has no cache options", metadata.ID)
}
options, err := storcli2CacheOptions(current.CacheOptions, desired)

— Claude Code

if err != nil {
return errors.Wrap(err, "failed to resolve cache options")
}

selector := fmt.Sprintf(storcli2VolumeSelector, metadata.CtrlMetadata.ID, metadata.ID)

for _, option := range options {
if err := s.set(selector, option); err != nil {
return errors.Wrapf(err, "failed to set %s", option)
}
}

return nil
}

// storcli2CacheOptions returns the "set" options for the policies that differ
// between current and desired, one per command (storcli2 rejects the combined
// syntax). A changed but unsettable (unknown) policy is an error.
func storcli2CacheOptions(current, desired *logicalvolume.CacheOptions) ([]string, error) {
var options []string

if desired.ReadPolicy != current.ReadPolicy {
token, ok := storcli2ReadCacheToken(desired.ReadPolicy)
if !ok {
return nil, errors.Errorf("unsettable read policy %q", desired.ReadPolicy)
}

options = append(options, storcli2RdCacheFlag+token)
}

if desired.WritePolicy != current.WritePolicy {
token, ok := storcli2WriteCacheToken(desired.WritePolicy)
if !ok {
return nil, errors.Errorf("unsettable write policy %q", desired.WritePolicy)
}

options = append(options, storcli2WrCacheFlag+token)
}

return options, nil
}

// set runs a single "set" command on a volume selector and surfaces the in-JSON
// failure that storcli2 may report regardless of its exit code.
func (s *StorCLI2) set(selector, option string) error {
output, err := s.StorCLI2.Run([]string{selector, storcli2CmdSet, option})
if err != nil {
return errors.Wrap(err, "failed to run set command")
}

if _, err := storcli2.Decode(output); err != nil {
return errors.Wrap(err, "set command failed")
}

return nil
}

// storcli2ReadCacheToken maps a read policy to its "rdcache" token. An unknown
// policy is not settable and yields ok=false.
func storcli2ReadCacheToken(policy logicalvolume.ReadPolicy) (string, bool) {
switch policy { //nolint:exhaustive // unknown handled by the default
case logicalvolume.ReadPolicyReadAhead:
return "RA", true
case logicalvolume.ReadPolicyNoReadAhead:
return "NoRA", true
default:
return "", false
}
}

// storcli2WriteCacheToken maps a write policy to its "wrcache" token. An unknown
// policy is not settable and yields ok=false.
func storcli2WriteCacheToken(policy logicalvolume.WritePolicy) (string, bool) {
switch policy { //nolint:exhaustive // unknown handled by the default
case logicalvolume.WritePolicyWriteThrough:
return "WT", true
case logicalvolume.WritePolicyWriteBack:
return "WB", true
case logicalvolume.WritePolicyAlwaysWriteBack:
return "AWB", true
default:
return "", false
}
}
198 changes: 198 additions & 0 deletions pkg/implementation/logicalvolumemanager/storcli2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package logicalvolumemanager_test

import (
"os"
"testing"

"github.com/pkg/errors"
"github.com/stretchr/testify/require"

"github.com/scality/raidmgmt/pkg/domain/entities/logicalvolume"
"github.com/scality/raidmgmt/pkg/domain/entities/raidcontroller"
"github.com/scality/raidmgmt/pkg/implementation/logicalvolumemanager"
)

// 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
}

func storcli2Metadata() *logicalvolume.Metadata {
return &logicalvolume.Metadata{
CtrlMetadata: &raidcontroller.Metadata{ID: 0},
ID: "25",
}
}

func newStorCLI2LV(cache *logicalvolume.CacheOptions) *logicalvolume.LogicalVolume {
return &logicalvolume.LogicalVolume{
Metadata: storcli2Metadata(),
CacheOptions: cache,
}
}

// TestStorCLI2SetLVCacheOptions covers the only-changed-flag behavior: each
// policy is set through its own command, and an unchanged policy emits no
// command at all.
func TestStorCLI2SetLVCacheOptions(t *testing.T) {
t.Parallel()

tests := []struct {
name string
current *logicalvolume.CacheOptions
desired *logicalvolume.CacheOptions
// calls maps the expected "set" option to the fixture it returns.
calls map[string]string
}{
{
name: "only read changed",
current: &logicalvolume.CacheOptions{
ReadPolicy: logicalvolume.ReadPolicyNoReadAhead,
WritePolicy: logicalvolume.WritePolicyWriteBack,
},
desired: &logicalvolume.CacheOptions{
ReadPolicy: logicalvolume.ReadPolicyReadAhead,
WritePolicy: logicalvolume.WritePolicyWriteBack,
},
calls: map[string]string{"rdcache=RA": "cacheoptions/success_rdcache.json"},
},
{
name: "only write changed",
current: &logicalvolume.CacheOptions{
ReadPolicy: logicalvolume.ReadPolicyReadAhead,
WritePolicy: logicalvolume.WritePolicyWriteBack,
},
desired: &logicalvolume.CacheOptions{
ReadPolicy: logicalvolume.ReadPolicyReadAhead,
WritePolicy: logicalvolume.WritePolicyWriteThrough,
},
calls: map[string]string{"wrcache=WT": "cacheoptions/success_wrcache.json"},
},
{
name: "both changed",
current: &logicalvolume.CacheOptions{
ReadPolicy: logicalvolume.ReadPolicyNoReadAhead,
WritePolicy: logicalvolume.WritePolicyWriteBack,
},
desired: &logicalvolume.CacheOptions{
ReadPolicy: logicalvolume.ReadPolicyReadAhead,
WritePolicy: logicalvolume.WritePolicyWriteThrough,
},
calls: map[string]string{
"rdcache=RA": "cacheoptions/success_rdcache.json",
"wrcache=WT": "cacheoptions/success_wrcache.json",
},
},
{
name: "nothing changed",
current: &logicalvolume.CacheOptions{
ReadPolicy: logicalvolume.ReadPolicyReadAhead,
WritePolicy: logicalvolume.WritePolicyWriteThrough,
},
desired: &logicalvolume.CacheOptions{
ReadPolicy: logicalvolume.ReadPolicyReadAhead,
WritePolicy: logicalvolume.WritePolicyWriteThrough,
},
calls: map[string]string{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

mockRunner := new(MockCommandRunner)
mockGetter := new(MockLogicalVolumesGetter)

metadata := storcli2Metadata()
mockGetter.On("LogicalVolume", metadata).Return(newStorCLI2LV(tt.current), nil)

for option, fixture := range tt.calls {
mockRunner.On("Run", []string{"/c0/v25", "set", option}).
Return(storcli2Fixture(t, fixture), nil)
}

manager := logicalvolumemanager.NewStorCLI2(mockRunner, mockGetter)

err := manager.SetLVCacheOptions(metadata, tt.desired)
require.NoError(t, err)

mockRunner.AssertExpectations(t)
mockRunner.AssertNumberOfCalls(t, "Run", len(tt.calls))
})
}
}

// TestStorCLI2SetLVCacheOptionsGetterError pins that a failure to read the
// current state aborts before any command is run.
func TestStorCLI2SetLVCacheOptionsGetterError(t *testing.T) {
t.Parallel()

mockRunner := new(MockCommandRunner)
mockGetter := new(MockLogicalVolumesGetter)

metadata := storcli2Metadata()
mockGetter.On("LogicalVolume", metadata).
Return((*logicalvolume.LogicalVolume)(nil), errors.New("boom"))

manager := logicalvolumemanager.NewStorCLI2(mockRunner, mockGetter)

err := manager.SetLVCacheOptions(metadata, &logicalvolume.CacheOptions{
ReadPolicy: logicalvolume.ReadPolicyReadAhead,
})
require.Error(t, err)
mockRunner.AssertNotCalled(t, "Run")
}

// TestStorCLI2SetLVCacheOptionsCommandError pins that an in-JSON command
// failure (here storcli's rejected combined syntax, kept as a plain-text
// failure fixture) is surfaced.
func TestStorCLI2SetLVCacheOptionsCommandError(t *testing.T) {
t.Parallel()

mockRunner := new(MockCommandRunner)
mockGetter := new(MockLogicalVolumesGetter)

metadata := storcli2Metadata()
mockGetter.On("LogicalVolume", metadata).Return(newStorCLI2LV(&logicalvolume.CacheOptions{
ReadPolicy: logicalvolume.ReadPolicyNoReadAhead,
WritePolicy: logicalvolume.WritePolicyWriteBack,
}), nil)
mockRunner.On("Run", []string{"/c0/v25", "set", "rdcache=RA"}).
Return(storcli2Fixture(t, "cacheoptions/combined_syntax_error.json"), nil)

manager := logicalvolumemanager.NewStorCLI2(mockRunner, mockGetter)

err := manager.SetLVCacheOptions(metadata, &logicalvolume.CacheOptions{
ReadPolicy: logicalvolume.ReadPolicyReadAhead,
WritePolicy: logicalvolume.WritePolicyWriteBack,
})
require.Error(t, err)
}

// TestStorCLI2SetLVCacheOptionsUnsettable pins that an unknown desired policy
// (e.g. round-tripped from getter output) is rejected rather than emitted.
func TestStorCLI2SetLVCacheOptionsUnsettable(t *testing.T) {
t.Parallel()

mockRunner := new(MockCommandRunner)
mockGetter := new(MockLogicalVolumesGetter)

metadata := storcli2Metadata()
mockGetter.On("LogicalVolume", metadata).Return(newStorCLI2LV(&logicalvolume.CacheOptions{
ReadPolicy: logicalvolume.ReadPolicyReadAhead,
}), nil)

manager := logicalvolumemanager.NewStorCLI2(mockRunner, mockGetter)

err := manager.SetLVCacheOptions(metadata, &logicalvolume.CacheOptions{
ReadPolicy: logicalvolume.ReadPolicyUnknown,
})
require.Error(t, err)
mockRunner.AssertNotCalled(t, "Run")
}
Loading
Loading