Add iDRAC ephemeral boot media support

The Dell Redfish implementation slightly deviates from the DMTF Redfish
specification. One variation is the Dell specification's classification
of virtual media, in which the Dell variation adds support for virtual
CDs and virtual floppy drives [0]. In order to perform some actions on Dell
hardware, airshipctl needs support for vendor specific clients.

This change introduces ephemeral boot media support for iDRAC systems
using a proprietary API in the Dell client.

During the creation of this change, it was also observed that Redfish
calls fail when media is already inserted. This change adds a check
to eject media if media is already inserted.

[0] https://www.dell.com/support/manuals/us/en/04/idrac9-lifecycle-controller-v3.2-series/idrac_3.21.21.21_redfishapiguide/virtualmedia?guid=guid-d9e76cf6-627d-4cb9-a3de-3f2b88b74cfb&lang=en-us

Closes: #139

Co-authored-by: Drew Walters <andrew.walters@att.com>
Change-Id: Ic9fd9e1493b1ff1bb20e956ae5f821d137c74760
This commit is contained in:
Alan Meadows 2020-03-18 11:01:52 -07:00 committed by Drew Walters
parent cb59c859cb
commit e2076191b7
5 changed files with 130 additions and 6 deletions

View File

@ -30,6 +30,7 @@ import (
const ( const (
// ClientType is used by other packages as the identifier of the Redfish client. // ClientType is used by other packages as the identifier of the Redfish client.
ClientType string = "redfish" ClientType string = "redfish"
mediaEjectDelay = 30 * time.Second
systemActionRetries = 30 systemActionRetries = 30
systemRebootDelay = 2 * time.Second systemRebootDelay = 2 * time.Second
) )
@ -140,10 +141,27 @@ func (c *Client) SetVirtualMedia(ctx context.Context, isoPath string) error {
return err return err
} }
// Eject virtual media if it is already inserted
vMediaMgr, httpResp, err := c.RedfishAPI.GetManagerVirtualMedia(ctx, managerID, vMediaID)
if err = ScreenRedfishError(httpResp, err); err != nil {
return err
}
if *vMediaMgr.Inserted == true {
var emptyBody map[string]interface{}
_, httpResp, err = c.RedfishAPI.EjectVirtualMedia(ctx, managerID, vMediaID, emptyBody)
if err = ScreenRedfishError(httpResp, err); err != nil {
return err
}
time.Sleep(mediaEjectDelay)
}
vMediaReq := redfishClient.InsertMediaRequestBody{} vMediaReq := redfishClient.InsertMediaRequestBody{}
vMediaReq.Image = isoPath vMediaReq.Image = isoPath
vMediaReq.Inserted = true vMediaReq.Inserted = true
_, httpResp, err := c.RedfishAPI.InsertVirtualMedia(ctx, managerID, vMediaID, vMediaReq) _, httpResp, err = c.RedfishAPI.InsertVirtualMedia(ctx, managerID, vMediaID, vMediaReq)
return ScreenRedfishError(httpResp, err) return ScreenRedfishError(httpResp, err)
} }

View File

@ -267,6 +267,8 @@ func TestSetVirtualMediaInsertVirtualMediaError(t *testing.T) {
Return(testutil.GetMediaCollection([]string{"Cd"}), httpResp, nil) Return(testutil.GetMediaCollection([]string{"Cd"}), httpResp, nil)
m.On("GetManagerVirtualMedia", ctx, testutil.ManagerID, "Cd").Times(1). m.On("GetManagerVirtualMedia", ctx, testutil.ManagerID, "Cd").Times(1).
Return(testutil.GetVirtualMedia([]string{"CD"}), httpResp, nil) Return(testutil.GetVirtualMedia([]string{"CD"}), httpResp, nil)
m.On("GetManagerVirtualMedia", ctx, testutil.ManagerID, "Cd").Times(1).
Return(testutil.GetVirtualMedia([]string{"CD"}), httpResp, nil)
m.On("InsertVirtualMedia", context.Background(), testutil.ManagerID, "Cd", mock.Anything).Return( m.On("InsertVirtualMedia", context.Background(), testutil.ManagerID, "Cd", mock.Anything).Return(
redfishClient.RedfishError{}, &http.Response{StatusCode: 500}, redfishClient.GenericOpenAPIError{}) redfishClient.RedfishError{}, &http.Response{StatusCode: 500}, redfishClient.GenericOpenAPIError{})

View File

@ -15,17 +15,35 @@
package dell package dell
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
redfishAPI "opendev.org/airship/go-redfish/api" redfishAPI "opendev.org/airship/go-redfish/api"
redfishClient "opendev.org/airship/go-redfish/client" redfishClient "opendev.org/airship/go-redfish/client"
"opendev.org/airship/airshipctl/pkg/log"
"opendev.org/airship/airshipctl/pkg/remote/redfish" "opendev.org/airship/airshipctl/pkg/remote/redfish"
) )
const ( const (
// ClientType is used by other packages as the identifier of the Redfish client. // ClientType is used by other packages as the identifier of the Redfish client.
ClientType string = "redfish-dell" ClientType = "redfish-dell"
endpointImportSysCFG = "%s/redfish/v1/Managers/%s/Actions/Oem/EID_674_Manager.ImportSystemConfiguration"
vCDBootRequestBody = `{
"ShareParameters": {
"Target": "ALL"
},
"ImportBuffer": "<SystemConfiguration>
<Component FQDD=\"iDRAC.Embedded.1\">
<Attribute Name=\"ServerBoot.1#BootOnce\">Enabled</Attribute>
<Attribute Name=\"ServerBoot.1#FirstBootDevice\">VCD-DVD</Attribute>
</Component>
</SystemConfiguration>"
}`
) )
// Client is a wrapper around the standard airshipctl Redfish client. This allows vendor specific Redfish clients to // Client is a wrapper around the standard airshipctl Redfish client. This allows vendor specific Redfish clients to
@ -36,6 +54,71 @@ type Client struct {
RedfishCFG *redfishClient.Configuration RedfishCFG *redfishClient.Configuration
} }
type iDRACAPIRespErr struct {
Err iDRACAPIErr `json:"error"`
}
type iDRACAPIErr struct {
ExtendedInfo []iDRACAPIExtendedInfo `json:"@Message.ExtendedInfo"`
Code string `json:"code"`
Message string `json:"message"`
}
type iDRACAPIExtendedInfo struct {
Message string `json:"Message"`
Resolution string `json:"Resolution,omitempty"`
}
// SetEphemeralBootSourceByType sets the boot source of the ephemeral node to a virtual CD, "VCD-DVD".
func (c *Client) SetEphemeralBootSourceByType(ctx context.Context) error {
managerID, err := redfish.GetManagerID(ctx, c.RedfishAPI, c.EphemeralNodeID())
if err != nil {
return err
}
// NOTE(drewwalters96): Setting the boot device to a virtual media type requires an API request to the iDRAC
// actions API. The request is made below using the same HTTP client used by the Redfish API and exposed by the
// standard airshipctl Redfish client. Only iDRAC 9 >= 3.3 is supports this endpoint.
url := fmt.Sprintf(endpointImportSysCFG, c.RedfishCFG.BasePath, managerID)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(vCDBootRequestBody))
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
if auth, ok := ctx.Value(redfishClient.ContextBasicAuth).(redfishClient.BasicAuth); ok {
req.SetBasicAuth(auth.UserName, auth.Password)
}
httpResp, err := c.RedfishCFG.HTTPClient.Do(req)
if httpResp.StatusCode != http.StatusAccepted {
body, ok := ioutil.ReadAll(httpResp.Body)
if ok != nil {
log.Debugf("Malformed iDRAC response: %s", body)
return redfish.ErrRedfishClient{Message: "Unable to set boot device. Malformed iDRAC response."}
}
var iDRACResp iDRACAPIRespErr
ok = json.Unmarshal(body, &iDRACResp)
if ok != nil {
log.Debugf("Malformed iDRAC response: %s", body)
return redfish.ErrRedfishClient{Message: "Unable to set boot device. Malformed iDrac response."}
}
return redfish.ErrRedfishClient{
Message: fmt.Sprintf("Unable to set boot device. %s", iDRACResp.Err.ExtendedInfo[0]),
}
} else if err != nil {
return redfish.ErrRedfishClient{Message: fmt.Sprintf("Unable to set boot device. %v", err)}
}
defer httpResp.Body.Close()
return nil
}
// NewClient returns a client with the capability to make Redfish requests. // NewClient returns a client with the capability to make Redfish requests.
func NewClient(ephemeralNodeID string, func NewClient(ephemeralNodeID string,
isoPath string, isoPath string,

View File

@ -13,9 +13,13 @@
package dell package dell
import ( import (
"net/http"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
redfishMocks "opendev.org/airship/go-redfish/api/mocks"
redfishClient "opendev.org/airship/go-redfish/client"
) )
const ( const (
@ -25,10 +29,24 @@ const (
) )
func TestNewClient(t *testing.T) { func TestNewClient(t *testing.T) {
// NOTE(drewwalters96): The Dell client implementation of this method simply creates the standard Redfish
// client. This test verifies that the Dell client creates and stores an instance of the standard client.
// Create the Dell client
_, _, err := NewClient(ephemeralNodeID, isoPath, redfishURL, false, false, "username", "password") _, _, err := NewClient(ephemeralNodeID, isoPath, redfishURL, false, false, "username", "password")
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestSetEphemeralBootSourceByTypeGetSystemError(t *testing.T) {
m := &redfishMocks.RedfishAPI{}
defer m.AssertExpectations(t)
ctx, client, err := NewClient("invalid-server", isoPath, redfishURL, false, false, "", "")
assert.NoError(t, err)
// Mock redfish get system request
m.On("GetSystem", ctx, client.EphemeralNodeID()).Times(1).Return(redfishClient.ComputerSystem{},
&http.Response{StatusCode: 500}, redfishClient.GenericOpenAPIError{})
// Replace normal API client with mocked API client
client.RedfishAPI = m
err = client.SetEphemeralBootSourceByType(ctx)
assert.Error(t, err)
}

View File

@ -48,7 +48,10 @@ func GetVirtualMedia(types []string) redfishClient.VirtualMedia {
mediaTypes = append(mediaTypes, t) mediaTypes = append(mediaTypes, t)
} }
inserted := false
vMedia.MediaTypes = mediaTypes vMedia.MediaTypes = mediaTypes
vMedia.Inserted = &inserted
return vMedia return vMedia
} }