Skip to content

Commit 1be12d6

Browse files
authored
BMC: Added CLI commands runner and Serial Console access. (#419)
* BMC: Added CLI commands runner and Serial Console access. BMC's CLI commands can be run using the bmc.RunCLICommand method, which connects to the CLI via SSH. The method blocks until the command ends or a timeout happens, whatever comes first. The command's output is copied into the stdout & stderr string vars. The underlying SSH session is created and closed internally after the command finishes. The new method OpenSerialConsole returns a piped reader and writer interfaces that can be used to interactively receive/send commands/text to/from the serial console. Since the serial console is tunneled in a SSH session, the CloseSerialConsole method must be called to release that session to prevent leaks that would prevent CLI commands to fail due to maximum concurrent SSH sessions reached in the BMC. Also, added SSH port as parameter for BMC's New() method * Addressed comments from Nikita. * Added host on glog traces and errors. * Updated UTs to the new error messages.
1 parent 9d5d447 commit 1be12d6

112 files changed

Lines changed: 18276 additions & 16 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,59 @@ exit status 1
9696
```
9797
Please refer to the [secret pkg](./pkg/secret/secret.go)'s use of the validate method for more information.
9898

99+
### BMC Package
100+
The BMC package can be used to access the BMC's Redfish API, run BMC's CLI commands or getting the systems' serial console. Credentials for both Redfish and SSH user, the SSH port and timeouts need to be passed as parameters of the New() method. E.g.
101+
102+
```
103+
redfishUser := bmc.User{Name: "redfishuser1", Password: "redfishpass1"}
104+
sshUser := bmc.User{Name: "sshuser1", Password: "sshpass1"}
105+
timeOuts := bmc.TimeOuts{Redfish: 10*time.Second, SSH: 10*time.Second}
106+
107+
bmc, err := bmc.New("1.2.3.4", redfishUser, sshUser, 22, timeOuts)
108+
```
109+
110+
You can check an example program for the BMC package [here](usage/bmc/bmc.go).
111+
112+
#### BMC's Redfish API
113+
The access to BMC's Redfish API is done by methods that encapsulate the underlaying HTTP calls made by the external gofish library. The redfish system index is defaulted to 0, but it can be changed with `SetSystemIndex()`:
114+
```
115+
const systemIndex = 3
116+
err = bmc.SetSystemIndex(systemIndex)
117+
if err != nil {
118+
...
119+
}
120+
121+
manufacturer, err := bmc.SystemManufacturer()
122+
if err != nil {
123+
...
124+
}
125+
126+
fmt.Printf("System %d's manufacturer: %v", systemIndex, manufacturer)
127+
128+
```
129+
130+
#### BMC's CLI
131+
The method `RunCLICommand` has been implemented to run CLI commands.
132+
```
133+
func (bmc *BMC) RunCLICommand(cmd string, combineOutput bool, timeout time.Duration) stdout string, stderr string, err error)
134+
```
135+
This method is not interactive: it blocks the caller until the command ends, copying its output into stdout and stderr strings.
136+
137+
#### Serial Console
138+
The method `OpenSerialConsole` can be used to get the systems's serial console, which is tunneled in the an underlaying SSH session.
139+
```
140+
func (bmc *BMC) OpenSerialConsole(openConsoleCliCmd string) (io.Reader, io.WriteCloser, error)
141+
```
142+
The user gets a (piped) reader and writer interfaces in order to read the output or write custom input (like CLI commands) in a interactive fashion.
143+
A use case for this is a test case that needs to wait for some pattern to appear in the system's serial console after rebooting the system.
144+
145+
The `openConsoleCliCmd` is the command that will be sent to the BMC's (SSH'd) CLI to open the serial console. In case the user doesn't know the command,
146+
it can be left empty. In that case, there's a best effort mechanism that will try to guess the CLI command based on the system's manufacturer, which will
147+
be internally retrieved using the Redfish API.
148+
149+
It's important to close the serial console using the method `bmc.CloseSerialConsole()`, which closes the underlying SSH session. Otherwise, BMC's can reach
150+
the maximum number of concurrent SSH sessions making other (SSH'd CLI) commands to fail. See an example program [here](usage/bmc/bmc.go).
151+
99152
# eco-goinfra - How to contribute
100153

101154
The project uses a development method - forking workflow

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ require (
5454
github.com/stmcginnis/gofish v0.15.0
5555
github.com/stretchr/testify v1.9.0
5656
github.com/vmware-tanzu/velero v1.12.1
57+
golang.org/x/crypto v0.23.0
5758
open-cluster-management.io/api v0.12.0
5859
)
5960

@@ -192,7 +193,6 @@ require (
192193
go.mongodb.org/mongo-driver v1.11.1 // indirect
193194
go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect
194195
go4.org v0.0.0-20200104003542-c7e774b10ea0 // indirect
195-
golang.org/x/crypto v0.23.0 // indirect
196196
golang.org/x/mod v0.15.0 // indirect
197197
golang.org/x/oauth2 v0.15.0 // indirect
198198
golang.org/x/sync v0.7.0 // indirect

pkg/bmc/bmc.go

Lines changed: 220 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
package bmc
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"fmt"
8+
"io"
79
"time"
810

911
"github.com/golang/glog"
1012
"github.com/stmcginnis/gofish"
1113
"github.com/stmcginnis/gofish/redfish"
14+
"golang.org/x/crypto/ssh"
1215
)
1316

1417
const (
1518
defaultTimeOut = 5 * time.Second
19+
20+
manufacturerDell = "Dell Inc."
21+
manufacturerHPE = "HPE"
1622
)
1723

1824
var (
@@ -21,6 +27,12 @@ var (
2127
Redfish: defaultTimeOut,
2228
SSH: defaultTimeOut,
2329
}
30+
31+
// CLI command to get the serial console (virtual serial port).
32+
cliCmdSerialConsole = map[string]string{
33+
manufacturerHPE: "VSP",
34+
manufacturerDell: "console com2",
35+
}
2436
)
2537

2638
// User holds the Name and Password for a user (ssh/redfish).
@@ -44,14 +56,17 @@ type BMC struct {
4456
host string
4557
redfishUser User
4658
sshUser User
59+
sshPort uint16
4760
systemIndex int
4861

4962
timeOuts TimeOuts
63+
64+
sshSessionForSerialConsole *ssh.Session
5065
}
5166

5267
// New returns a new BMC struct. The default system index to be used in redfish requests is 0.
5368
// Use SetSystemIndex to modify it.
54-
func New(host string, redfishUser, sshUser User, timeOuts TimeOuts) (*BMC, error) {
69+
func New(host string, redfishUser, sshUser User, sshPort uint16, timeOuts TimeOuts) (*BMC, error) {
5570
glog.V(100).Infof("Initializing new BMC structure with the following params: %s, %v, %v, %v (system index = 0)",
5671
host, redfishUser, sshUser, timeOuts)
5772

@@ -72,6 +87,10 @@ func New(host string, redfishUser, sshUser User, timeOuts TimeOuts) (*BMC, error
7287
errMsgs = append(errMsgs, "ssh user's name is empty")
7388
}
7489

90+
if sshPort == 0 {
91+
errMsgs = append(errMsgs, "ssh port is zero")
92+
}
93+
7594
if sshUser.Password == "" {
7695
errMsgs = append(errMsgs, "ssh user's password is empty")
7796
}
@@ -105,6 +124,7 @@ func New(host string, redfishUser, sshUser User, timeOuts TimeOuts) (*BMC, error
105124
host: host,
106125
redfishUser: redfishUser,
107126
sshUser: sshUser,
127+
sshPort: sshPort,
108128
timeOuts: timeOuts,
109129
systemIndex: 0,
110130
}, nil
@@ -347,3 +367,202 @@ func redfishGetSystemSecureBoot(redfishClient *gofish.APIClient, systemIndex int
347367

348368
return sboot, nil
349369
}
370+
371+
// CreateCLISSHSession creates a ssh Session to the host.
372+
func (bmc *BMC) CreateCLISSHSession() (*ssh.Session, error) {
373+
glog.V(100).Infof("Creating SSH session to run commands in the BMC's CLI.")
374+
375+
config := &ssh.ClientConfig{
376+
User: bmc.sshUser.Name,
377+
Auth: []ssh.AuthMethod{
378+
ssh.Password(bmc.sshUser.Password),
379+
ssh.KeyboardInteractive(func(user, instruction string, questions []string,
380+
echos []bool) (answers []string, err error) {
381+
answers = make([]string, len(questions))
382+
// The second parameter is unused
383+
for n := range questions {
384+
answers[n] = bmc.sshUser.Password
385+
}
386+
387+
return answers, nil
388+
}),
389+
},
390+
Timeout: bmc.timeOuts.SSH,
391+
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
392+
}
393+
394+
// Establish SSH connection
395+
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", bmc.host, bmc.sshPort), config)
396+
if err != nil {
397+
glog.V(100).Infof("Failed to connect to BMC's SSH server: %v", err)
398+
399+
return nil, fmt.Errorf("failed to connect to BMC's SSH server: %w", err)
400+
}
401+
402+
// Create a session
403+
session, err := client.NewSession()
404+
if err != nil {
405+
glog.V(100).Infof("Failed to create a new SSH session: %v", err)
406+
407+
return nil, fmt.Errorf("failed to create a new ssh session: %w", err)
408+
}
409+
410+
return session, nil
411+
}
412+
413+
// RunCLICommand runs a CLI command in the BMC's console. This method will block until the command
414+
// has finished, and its output is copied to stdout and/or stderr if applicable. If combineOutput is true,
415+
// stderr content is merged in stdout. The timeout param is used to avoid the caller to be stuck forever
416+
// in case something goes wrong or the command is stuck.
417+
func (bmc *BMC) RunCLICommand(cmd string, combineOutput bool, timeout time.Duration) (
418+
stdout string, stderr string, err error) {
419+
glog.V(100).Infof("Running CLI command in BMC's CLI: %s", cmd)
420+
421+
sshSession, err := bmc.CreateCLISSHSession()
422+
if err != nil {
423+
glog.V(100).Infof("Failed to connect to CLI: %v", err)
424+
425+
return "", "", fmt.Errorf("failed to connect to CLI: %w", err)
426+
}
427+
428+
defer sshSession.Close()
429+
430+
var stdoutBuffer, stderrBuffer bytes.Buffer
431+
if !combineOutput {
432+
sshSession.Stdout = &stdoutBuffer
433+
sshSession.Stderr = &stderrBuffer
434+
}
435+
436+
var combinedOutput []byte
437+
438+
errCh := make(chan error)
439+
go func() {
440+
var err error
441+
if combineOutput {
442+
combinedOutput, err = sshSession.CombinedOutput(cmd)
443+
} else {
444+
err = sshSession.Run(cmd)
445+
}
446+
errCh <- err
447+
}()
448+
449+
timeoutCh := time.After(timeout)
450+
451+
select {
452+
case <-timeoutCh:
453+
glog.V(100).Info("CLI command timeout")
454+
455+
return stdoutBuffer.String(), stderrBuffer.String(), fmt.Errorf("timeout running command")
456+
case err := <-errCh:
457+
glog.V(100).Info("Command run error: %v", err)
458+
459+
if err != nil {
460+
return stdoutBuffer.String(), stderrBuffer.String(), fmt.Errorf("command run error: %w", err)
461+
}
462+
}
463+
464+
if combineOutput {
465+
return string(combinedOutput), "", nil
466+
}
467+
468+
return stdoutBuffer.String(), stderrBuffer.String(), nil
469+
}
470+
471+
// OpenSerialConsole opens the serial console port. The console is tunneled in an underlying (CLI)
472+
// ssh session that is opened in the BMC's ssh server. If openConsoleCliCmd is
473+
// provided, it will be sent to the BMC's cli. Otherwise, a best effort will
474+
// be made to run the appropriate cli command based on the system manufacturer.
475+
func (bmc *BMC) OpenSerialConsole(openConsoleCliCmd string) (io.Reader, io.WriteCloser, error) {
476+
glog.V(100).Infof("Opening serial console on %v.", bmc.host)
477+
478+
if bmc.sshSessionForSerialConsole != nil {
479+
glog.V(100).Infof("There is already a serial console opened for %v's BMC. Use OpenSerialConsole() first.",
480+
bmc.host)
481+
482+
return nil, nil, fmt.Errorf("there is already a serial console opened for %v's BMC", bmc.host)
483+
}
484+
485+
cliCmd := openConsoleCliCmd
486+
if cliCmd == "" {
487+
// no cli command to get console port was provided, try to guess based on
488+
// manufacturer.
489+
manufacturer, err := bmc.SystemManufacturer()
490+
if err != nil {
491+
glog.V(100).Infof("Failed to get redifsh system manufacturer for %v: %v", bmc.host, err)
492+
493+
return nil, nil, fmt.Errorf("failed to get redfish system manufacturer for %v: %w", bmc.host, err)
494+
}
495+
496+
var found bool
497+
if cliCmd, found = cliCmdSerialConsole[manufacturer]; !found {
498+
glog.V(100).Infof("CLI command to get serial console not found for manufacturer for %v: %v",
499+
bmc.host, manufacturer)
500+
501+
return nil, nil, fmt.Errorf("cli command to get serial console not found for manufacturer for %v: %v",
502+
bmc.host, manufacturer)
503+
}
504+
}
505+
506+
sshSession, err := bmc.CreateCLISSHSession()
507+
if err != nil {
508+
glog.V(100).Infof("Failed to create underlying ssh session for %v: %v", bmc.host, err)
509+
510+
return nil, nil, fmt.Errorf("failed to create underlying ssh session for %v: %w", bmc.host, err)
511+
}
512+
513+
// Pipes need to be retrieved before session.Start()
514+
reader, err := sshSession.StdoutPipe()
515+
if err != nil {
516+
glog.V(100).Infof("Failed to get stdout pipe from %v's ssh session: %v", bmc.host, err)
517+
518+
_ = sshSession.Close()
519+
520+
return nil, nil, fmt.Errorf("failed to get stdout pipe from %v's ssh session: %w", bmc.host, err)
521+
}
522+
523+
writer, err := sshSession.StdinPipe()
524+
if err != nil {
525+
glog.V(100).Infof("Failed to get stdin pipe from from %v's ssh session: %w", bmc.host, err)
526+
527+
_ = sshSession.Close()
528+
529+
return nil, nil, fmt.Errorf("failed to get stdin pipe from %v's ssh session: %w", bmc.host, err)
530+
}
531+
532+
err = sshSession.Start(cliCmd)
533+
if err != nil {
534+
glog.V(100).Infof("Failed to start CLI command %q on %v: %v", cliCmd, bmc.host, err)
535+
536+
_ = sshSession.Close()
537+
538+
return nil, nil, fmt.Errorf("failed to start serial console with cli command %q on %v: %w", cliCmd, bmc.host, err)
539+
}
540+
541+
go func() { _ = sshSession.Wait() }()
542+
543+
bmc.sshSessionForSerialConsole = sshSession
544+
545+
return reader, writer, nil
546+
}
547+
548+
// CloseSerialConsole closes the serial console's underlying ssh session.
549+
func (bmc *BMC) CloseSerialConsole() error {
550+
glog.V(100).Infof("Closing serial console for %v.", bmc.host)
551+
552+
if bmc.sshSessionForSerialConsole == nil {
553+
glog.V(100).Infof("No underlying ssh session found for %v. Please use OpenSerialConsole() first.", bmc.host)
554+
555+
return fmt.Errorf("no underlying ssh session found for %v", bmc.host)
556+
}
557+
558+
err := bmc.sshSessionForSerialConsole.Close()
559+
if err != nil {
560+
glog.V(100).Infof("Failed to close underlying ssh session for %v: %v", bmc.host, err)
561+
562+
return fmt.Errorf("failed to close underlying ssh session for %v: %w", bmc.host, err)
563+
}
564+
565+
bmc.sshSessionForSerialConsole = nil
566+
567+
return nil
568+
}

0 commit comments

Comments
 (0)