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
34 changes: 34 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,37 @@ This adapter has the following limitations compared to hardware RAID:
The `core.RAIDController` wraps any adapter and adds input validation before
delegating to the underlying implementation. This is the recommended entry
point for consumers of the library.

## Command Logging

Every host mutation and every inventory read this library performs is a vendor
CLI invocation behind a `commandrunner.CommandRunner`, which makes that
interface the one place where all executed commands can be observed.

Logging is therefore a **decorator** on that port rather than a concern spread
across the adapters: `commandrunner.Logging` wraps any `CommandRunner`, records
the invocation on an `slog.Logger` (`log/slog`, no new dependency), and returns
the wrapped runner's output and error unchanged. It is opt-in -- a consumer
that wants a command log injects the decorated runner where it would have
injected the concrete one -- and it covers new runners and new adapters for
free, since they all sit behind the same port.

Design notes:

- One record per invocation, at `slog.LevelInfo` by default
(`commandrunner.WithLevel` lowers it) and at `slog.LevelError` on failure.
`commandrunner.ErrNoLogicalDrives` reports an empty inventory rather than a
failure, so it stays at the success level.
- The record carries the binary, the arguments, the duration and, on failure,
the error. The **output is never logged**: vendor payloads carry drive
serials and other identifying data, so only its size is recorded.
- The arguments are the ones the adapter asked for. A runner that appends flags
of its own (the storcli2/perccli2 JSON output flag) does so after the
decorator has seen them, so they are absent from the record.
- Runners report the binary they invoke through `CommandPath()`, so a record
names the tool that ran; the decorator forwards its wrapped runner's path,
and falls back to the runner's type for a runner without one (a test mock).
- The legacy `megaraid.Runner` returns parsed output instead of bytes, so it
cannot share the decorator. `megaraid.LoggingRunner` decorates it in the same
way and emits through the same `commandrunner.LogCommand`, keeping one log
shape across the library.
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ setup.
identification blinking.
- **Extensible** -- New controllers can be added by implementing the adapter
interfaces.
- **Command logging** -- Every vendor CLI command the library executes can be
logged to an `slog.Logger` by wrapping the command runner.

## Installation

Expand Down Expand Up @@ -85,6 +87,43 @@ func main() {
> the storcli2 write path lands; until then the storcli2 components are wired
> individually.

### Command logging

Everything this library does on a host goes through a
`commandrunner.CommandRunner`, so wrapping the runner in
`commandrunner.NewLogging` logs every command executed, whichever adapter
issued it:

```go
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

runner := commandrunner.NewLogging(commandrunner.NewStorCLI2(nil), logger)

drives, err := physicaldrivegetter.NewStorCLI2(runner).PhysicalDrives(metadata)
```

Each executed command produces one record with the binary invoked, its
arguments, how long it took and the outcome:

```json
{"time":"2026-09-22T10:12:31Z","level":"INFO","msg":"RAID command executed",
"command":"/opt/MegaRAID/storcli2/storcli2","args":["/c0","show","all"],
"duration":41000000,"output_bytes":9317}
```

Details worth knowing:

- Successful commands are logged at `slog.LevelInfo`; pass
`commandrunner.WithLevel(slog.LevelDebug)` to lower that. Failures are always
logged at `slog.LevelError`, with the error attached.
- The command **output is never logged** -- vendor payloads carry drive serials
and other identifying data -- only its size.
- The arguments logged are the ones the adapter asked for: flags a runner adds
itself (storcli2 and perccli2 append the JSON output flag) are not shown.
- The decorator is transparent, so it can wrap any runner, including the ones
injected into a full `raidcontroller` composition. The legacy
`megaraid.Runner` has its own equivalent, `megaraid.NewLoggingRunner`.

## Project Structure

```
Expand Down
149 changes: 149 additions & 0 deletions pkg/implementation/commandrunner/logging.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package commandrunner

import (
"context"
"fmt"
"log/slog"
"time"

"github.com/pkg/errors"
)

// ExecutedCommand describes a single vendor CLI invocation, as handed to
// LogCommand.
type ExecutedCommand struct {
// Path is the binary that was invoked.
Path string

// Args are the arguments the caller asked for.
Args []string

// OutputBytes is the size of the command output. The output itself is
// never logged: vendor payloads carry drive serials and other identifying
// data. A runner that exposes no payload size leaves this zero, and the
// attribute is then omitted.
OutputBytes int

// Duration is how long the invocation took.
Duration time.Duration

// Err is the error the invocation returned, if any.
Err error
}

// LogCommand writes one record describing an executed command: a successful
// invocation at level, a failed one at slog.LevelError. ErrNoLogicalDrives
// reports an empty inventory rather than a failure (see the SSACLI runner), so
// it is recorded at level too, with its error attached.
//
// It is exported so that a command runner outside this package -- the legacy
// megaraid.Runner, whose Run returns parsed output instead of bytes -- logs in
// the same shape.
func LogCommand(logger *slog.Logger, level slog.Level, cmd ExecutedCommand) {
attrs := []slog.Attr{
slog.String("command", cmd.Path),
slog.Any("args", cmd.Args),
slog.Duration("duration", cmd.Duration),
}

if cmd.OutputBytes > 0 {
attrs = append(attrs, slog.Int("output_bytes", cmd.OutputBytes))
}

recordLevel, message := level, "RAID command executed"

if cmd.Err != nil {
attrs = append(attrs, slog.String("error", cmd.Err.Error()))

if !errors.Is(cmd.Err, ErrNoLogicalDrives) {
recordLevel, message = slog.LevelError, "RAID command failed"
}
}

logger.LogAttrs(context.Background(), recordLevel, message, attrs...)
}

// Logging decorates a CommandRunner so that every command it executes is
// recorded on an slog.Logger: the binary invoked, its arguments, how long it
// took and the outcome. The decorator is transparent -- output and error are
// returned unchanged -- so it wraps any runner without altering adapter
// behaviour.
//
// The arguments recorded are the ones the adapter asked for. A runner that adds
// flags of its own does so after this decorator has seen the arguments, so
// those flags are absent from the record: a storcli2 or perccli2 command also
// carries the JSON output flag on the wire.
type Logging struct {
runner CommandRunner
logger *slog.Logger
level slog.Level
}

// LoggingOption configures a Logging runner.
type LoggingOption func(*Logging)

// WithLevel sets the level successful commands are recorded at. It defaults to
// slog.LevelInfo; failures are always recorded at slog.LevelError.
func WithLevel(level slog.Level) LoggingOption {
return func(l *Logging) {
l.level = level
}
}

// commandPather is implemented by the runners of this package to report the
// binary they invoke, so that a log record names the tool that ran.
type commandPather interface {
CommandPath() string
}

var _ CommandRunner = &Logging{}

// NewLogging wraps runner so that every command it runs is logged to logger. A
// nil logger falls back to slog.Default().
func NewLogging(runner CommandRunner, logger *slog.Logger, opts ...LoggingOption) *Logging {
target := logger
if target == nil {
target = slog.Default()
}

logging := &Logging{
runner: runner,
logger: target,
level: slog.LevelInfo,
}

for _, opt := range opts {
opt(logging)
}

return logging
}

// Run logs the command, then returns the wrapped runner's output and error
// unchanged.
func (l *Logging) Run(args []string) ([]byte, error) {
start := time.Now()

output, err := l.runner.Run(args)

LogCommand(l.logger, l.level, ExecutedCommand{
Path: l.CommandPath(),
Args: args,
OutputBytes: len(output),
Duration: time.Since(start),
Err: err,
})

return output, err //nolint:wrapcheck // Transparent decorator: error returned as-is.
}

// CommandPath reports the binary the wrapped runner invokes, which also keeps
// the decorator itself transparent to another decorator wrapping it. A runner
// that does not report its binary is named by its type.
func (l *Logging) CommandPath() string {
if pather, ok := l.runner.(commandPather); ok {
return pather.CommandPath()
}

return fmt.Sprintf("%T", l.runner)
}
Loading