From bd51d1f31b7424bcac8885e4fa10dc8e1c29e81f Mon Sep 17 00:00:00 2001 From: Drew Walters <andrew.walters@att.com> Date: Wed, 25 Mar 2020 21:31:48 +0000 Subject: [PATCH] Add remote Client interface The remote package in airshipctl is tightly coupled to redfish. In the future, we may need to introduce IPMI or SMASH; however, adding those clients now would be difficult because of our tight dependence on redfish. This change adds a Client interface, remote.Client, that will be implemented by all OOB clients (i.e. Redfish, SMASH, IPMI) in order to satisfy remoteDirect and future power commands. This change also creates a Redfish client that implements the client. A future change will remove the old Redfish client and de-couple the remoteDirect functionality from the redfish package. Relates #5, #122 Change-Id: Id9fe09e74efef0c4fcd5b92a1c12897217a4dae1 Signed-off-by: Drew Walters <andrew.walters@att.com> --- pkg/remote/redfish/client.go | 202 ++++++++++++++++++++++ pkg/remote/redfish/client_test.go | 278 ++++++++++++++++++++++++++++++ pkg/remote/remote_direct.go | 6 +- pkg/remote/types.go | 32 ++++ 4 files changed, 515 insertions(+), 3 deletions(-) create mode 100644 pkg/remote/redfish/client.go create mode 100644 pkg/remote/redfish/client_test.go create mode 100644 pkg/remote/types.go diff --git a/pkg/remote/redfish/client.go b/pkg/remote/redfish/client.go new file mode 100644 index 000000000..2e9b74a07 --- /dev/null +++ b/pkg/remote/redfish/client.go @@ -0,0 +1,202 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package redfish + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + redfishAPI "opendev.org/airship/go-redfish/api" + redfishClient "opendev.org/airship/go-redfish/client" + + "opendev.org/airship/airshipctl/pkg/log" +) + +const ( + // ClientType is used by other packages as the identifier of the Redfish client. + ClientType string = "redfish" + systemActionRetries = 30 + systemRebootDelay = 2 * time.Second +) + +// Client holds details about a Redfish out-of-band system required for out-of-band management. +type Client struct { + ephemeralNodeID string + isoPath string + redfishURL url.URL + redfishAPI redfishAPI.RedfishAPI +} + +// EphemeralNodeID retrieves the ephemeral node ID. +func (c *Client) EphemeralNodeID() string { + return c.ephemeralNodeID +} + +// RebootSystem power cycles a host by sending a shutdown signal followed by a power on signal. +func (c *Client) RebootSystem(ctx context.Context, systemID string) error { + waitForPowerState := func(desiredState redfishClient.PowerState) error { + // Check if number of retries is defined in context + totalRetries, ok := ctx.Value("numRetries").(int) + if !ok { + totalRetries = systemActionRetries + } + + for retry := 0; retry <= totalRetries; retry++ { + system, httpResp, err := c.redfishAPI.GetSystem(ctx, systemID) + if err = ScreenRedfishError(httpResp, err); err != nil { + return err + } + if system.PowerState == desiredState { + return nil + } + time.Sleep(systemRebootDelay) + } + return ErrOperationRetriesExceeded{} + } + + resetReq := redfishClient.ResetRequestBody{} + + // Send PowerOff request + resetReq.ResetType = redfishClient.RESETTYPE_FORCE_OFF + _, httpResp, err := c.redfishAPI.ResetSystem(ctx, systemID, resetReq) + if err = ScreenRedfishError(httpResp, err); err != nil { + return err + } + + // Check that node is powered off + if err = waitForPowerState(redfishClient.POWERSTATE_OFF); err != nil { + return err + } + + // Send PowerOn request + resetReq.ResetType = redfishClient.RESETTYPE_ON + _, httpResp, err = c.redfishAPI.ResetSystem(ctx, systemID, resetReq) + if err = ScreenRedfishError(httpResp, err); err != nil { + return err + } + + // Check that node is powered on and return + return waitForPowerState(redfishClient.POWERSTATE_ON) +} + +// SetEphemeralBootSourceByType sets the boot source of the ephemeral node to one that's compatible with the boot +// source type. +func (c *Client) SetEphemeralBootSourceByType(ctx context.Context, mediaType string) error { + // Retrieve system information, containing available boot sources + system, _, err := c.redfishAPI.GetSystem(ctx, c.ephemeralNodeID) + if err != nil { + return ErrRedfishClient{Message: fmt.Sprintf("Get System[%s] failed with err: %v", c.ephemeralNodeID, err)} + } + + allowableValues := system.Boot.BootSourceOverrideTargetRedfishAllowableValues + for _, bootSource := range allowableValues { + if strings.EqualFold(string(bootSource), mediaType) { + /* set boot source */ + systemReq := redfishClient.ComputerSystem{} + systemReq.Boot.BootSourceOverrideTarget = bootSource + _, httpResp, err := c.redfishAPI.SetSystem(ctx, c.ephemeralNodeID, systemReq) + return ScreenRedfishError(httpResp, err) + } + } + + return ErrRedfishClient{Message: fmt.Sprintf("failed to set system[%s] boot source", c.ephemeralNodeID)} +} + +// SetVirtualMedia injects a virtual media device to an established virtual media ID. This assumes that isoPath is +// accessible to the redfish server and virtualMedia device is either of type CD or DVD. +func (c *Client) SetVirtualMedia(ctx context.Context, vMediaID string, isoPath string) error { + system, _, err := c.redfishAPI.GetSystem(ctx, c.ephemeralNodeID) + if err != nil { + return ErrRedfishClient{Message: fmt.Sprintf("Get System[%s] failed with err: %v", c.ephemeralNodeID, err)} + } + + log.Debugf("Ephemeral Node System ID: '%s'", c.ephemeralNodeID) + + managerID := GetResourceIDFromURL(system.Links.ManagedBy[0].OdataId) + log.Debugf("Ephemeral node managerID: '%s'", managerID) + + vMediaReq := redfishClient.InsertMediaRequestBody{} + vMediaReq.Image = isoPath + vMediaReq.Inserted = true + _, httpResp, err := c.redfishAPI.InsertVirtualMedia(ctx, managerID, vMediaID, vMediaReq) + return ScreenRedfishError(httpResp, err) +} + +// NewClient returns a client with the capability to make Redfish requests. +func NewClient(ephemeralNodeID string, + isoPath string, + redfishURL string, + insecure bool, + useProxy bool, + username string, + password string) (context.Context, *Client, error) { + var ctx context.Context + if username != "" && password != "" { + ctx = context.WithValue( + context.Background(), + redfishClient.ContextBasicAuth, + redfishClient.BasicAuth{UserName: username, Password: password}, + ) + } else { + ctx = context.Background() + } + + if redfishURL == "" { + return ctx, nil, ErrRedfishMissingConfig{What: "Redfish URL"} + } + + parsedURL, err := url.Parse(redfishURL) + if err != nil { + return ctx, nil, err + } + + cfg := &redfishClient.Configuration{ + BasePath: redfishURL, + DefaultHeader: make(map[string]string), + UserAgent: "airshipctl/client", + } + // see https://github.com/golang/go/issues/26013 + // We clone the default transport to ensure when we customize the transport + // that we are providing it sane timeouts and other defaults that we would + // normally get when not overriding the transport + defaultTransportCopy := (http.DefaultTransport.(*http.Transport)) + transport := defaultTransportCopy.Clone() + + if insecure { + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec + } + } + + if !useProxy { + transport.Proxy = nil + } + + cfg.HTTPClient = &http.Client{ + Transport: transport, + } + + c := &Client{ + ephemeralNodeID: ephemeralNodeID, + isoPath: isoPath, + redfishURL: *parsedURL, + redfishAPI: redfishClient.NewAPIClient(cfg).DefaultApi, + } + + return ctx, c, nil +} diff --git a/pkg/remote/redfish/client_test.go b/pkg/remote/redfish/client_test.go new file mode 100644 index 000000000..7bd6a333f --- /dev/null +++ b/pkg/remote/redfish/client_test.go @@ -0,0 +1,278 @@ +package redfish + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + redfishMocks "opendev.org/airship/go-redfish/api/mocks" + redfishClient "opendev.org/airship/go-redfish/client" +) + +const ( + ephemeralNodeID = "ephemeral-node-id" + isoPath = "https://localhost:8080/debian.iso" + redfishURL = "https://localhost:1234" +) + +func getTestSystem() redfishClient.ComputerSystem { + return redfishClient.ComputerSystem{ + Id: "serverid-00", + Name: "server-100", + UUID: "58893887-8974-2487-2389-841168418919", + Status: redfishClient.Status{ + State: "Enabled", + Health: "OK", + }, + Links: redfishClient.SystemLinks{ + ManagedBy: []redfishClient.IdRef{ + {OdataId: "/redfish/v1/Managers/manager-1"}, + }, + }, + Boot: redfishClient.Boot{ + BootSourceOverrideTarget: redfishClient.BOOTSOURCE_CD, + BootSourceOverrideEnabled: redfishClient.BOOTSOURCEOVERRIDEENABLED_CONTINUOUS, + BootSourceOverrideTargetRedfishAllowableValues: []redfishClient.BootSource{ + redfishClient.BOOTSOURCE_CD, + redfishClient.BOOTSOURCE_FLOPPY, + redfishClient.BOOTSOURCE_HDD, + redfishClient.BOOTSOURCE_PXE, + }, + }, + } +} + +func TestNewClient(t *testing.T) { + _, _, err := NewClient(ephemeralNodeID, isoPath, redfishURL, false, false, "", "") + assert.NoError(t, err) +} + +func TestNewClientAuth(t *testing.T) { + ctx, _, err := NewClient(ephemeralNodeID, isoPath, redfishURL, false, false, "username", "password") + assert.NoError(t, err) + + cAuth := ctx.Value(redfishClient.ContextBasicAuth) + auth := redfishClient.BasicAuth{UserName: "username", Password: "password"} + assert.Equal(t, cAuth, auth) +} + +func TestNewClientEmptyRedfishURL(t *testing.T) { + // Redfish URL cannot be empty when creating a client. + _, _, err := NewClient(ephemeralNodeID, isoPath, "", false, false, "", "") + assert.Error(t, err) +} + +func TestRebootSystem(t *testing.T) { + m := &redfishMocks.RedfishAPI{} + defer m.AssertExpectations(t) + + ctx, client, err := NewClient(ephemeralNodeID, isoPath, redfishURL, false, false, "", "") + assert.NoError(t, err) + + // Mock redfish shutdown and status requests + resetReq := redfishClient.ResetRequestBody{} + resetReq.ResetType = redfishClient.RESETTYPE_FORCE_OFF + httpResp := &http.Response{StatusCode: 200} + m.On("ResetSystem", ctx, ephemeralNodeID, resetReq).Times(1).Return(redfishClient.RedfishError{}, httpResp, nil) + + m.On("GetSystem", ctx, ephemeralNodeID).Times(1).Return( + redfishClient.ComputerSystem{PowerState: redfishClient.POWERSTATE_OFF}, httpResp, nil) + + // Mock redfish startup and status requests + resetReq.ResetType = redfishClient.RESETTYPE_ON + m.On("ResetSystem", ctx, ephemeralNodeID, resetReq).Times(1).Return(redfishClient.RedfishError{}, httpResp, nil) + + m.On("GetSystem", ctx, ephemeralNodeID).Times(1). + Return(redfishClient.ComputerSystem{PowerState: redfishClient.POWERSTATE_ON}, httpResp, nil) + + // Replace normal API client with mocked API client + client.redfishAPI = m + + err = client.RebootSystem(ctx, ephemeralNodeID) + assert.NoError(t, err) +} + +func TestRebootSystemShutdownError(t *testing.T) { + m := &redfishMocks.RedfishAPI{} + defer m.AssertExpectations(t) + + ctx, client, err := NewClient(ephemeralNodeID, isoPath, redfishURL, false, false, "", "") + assert.NoError(t, err) + + resetReq := redfishClient.ResetRequestBody{} + resetReq.ResetType = redfishClient.RESETTYPE_FORCE_OFF + + // Mock redfish shutdown request for failure + m.On("ResetSystem", ctx, ephemeralNodeID, resetReq).Times(1).Return(redfishClient.RedfishError{}, + &http.Response{StatusCode: 401}, redfishClient.GenericOpenAPIError{}) + + // Replace normal API client with mocked API client + client.redfishAPI = m + + err = client.RebootSystem(ctx, ephemeralNodeID) + _, ok := err.(ErrRedfishClient) + assert.True(t, ok) +} + +func TestRebootSystemStartupError(t *testing.T) { + m := &redfishMocks.RedfishAPI{} + defer m.AssertExpectations(t) + + ctx, client, err := NewClient(ephemeralNodeID, isoPath, redfishURL, false, false, "", "") + assert.NoError(t, err) + + resetReq := redfishClient.ResetRequestBody{} + resetReq.ResetType = redfishClient.RESETTYPE_FORCE_OFF + + // Mock redfish shutdown request + m.On("ResetSystem", ctx, systemID, resetReq).Times(1).Return(redfishClient.RedfishError{}, + &http.Response{StatusCode: 200}, nil) + + m.On("GetSystem", ctx, systemID).Times(1).Return( + redfishClient.ComputerSystem{PowerState: redfishClient.POWERSTATE_OFF}, + &http.Response{StatusCode: 200}, nil) + + resetOnReq := redfishClient.ResetRequestBody{} + resetOnReq.ResetType = redfishClient.RESETTYPE_ON + + // Mock redfish startup request for failure + m.On("ResetSystem", ctx, systemID, resetOnReq).Times(1).Return(redfishClient.RedfishError{}, + &http.Response{StatusCode: 401}, redfishClient.GenericOpenAPIError{}) + + // Replace normal API client with mocked API client + client.redfishAPI = m + + err = client.RebootSystem(ctx, systemID) + _, ok := err.(ErrRedfishClient) + assert.True(t, ok) +} + +func TestRebootSystemTimeout(t *testing.T) { + m := &redfishMocks.RedfishAPI{} + defer m.AssertExpectations(t) + + _, client, err := NewClient(ephemeralNodeID, isoPath, redfishURL, false, false, "", "") + assert.NoError(t, err) + + ctx := context.WithValue(context.Background(), "numRetries", 1) + resetReq := redfishClient.ResetRequestBody{} + resetReq.ResetType = redfishClient.RESETTYPE_FORCE_OFF + m.On("ResetSystem", ctx, systemID, resetReq). + Times(1). + Return(redfishClient.RedfishError{}, &http.Response{StatusCode: 200}, nil) + + m.On("GetSystem", ctx, systemID). + Return(redfishClient.ComputerSystem{}, &http.Response{StatusCode: 200}, nil) + + // Replace normal API client with mocked API client + client.redfishAPI = m + + err = client.RebootSystem(ctx, systemID) + assert.Equal(t, ErrOperationRetriesExceeded{}, 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{}, + nil, redfishClient.GenericOpenAPIError{}) + + // Replace normal API client with mocked API client + client.redfishAPI = m + + err = client.SetEphemeralBootSourceByType(ctx, "CD") + _, ok := err.(ErrRedfishClient) + assert.True(t, ok) +} + +func TestSetEphemeralBootSourceByTypeSetSystemError(t *testing.T) { + m := &redfishMocks.RedfishAPI{} + defer m.AssertExpectations(t) + + ctx, client, err := NewClient("invalid-server", isoPath, redfishURL, false, false, "", "") + assert.NoError(t, err) + + m.On("GetSystem", ctx, client.ephemeralNodeID).Return(getTestSystem(), + &http.Response{StatusCode: 200}, nil) + m.On("SetSystem", ctx, client.ephemeralNodeID, mock.Anything).Times(1).Return( + redfishClient.ComputerSystem{}, &http.Response{StatusCode: 401}, redfishClient.GenericOpenAPIError{}) + + // Replace normal API client with mocked API client + client.redfishAPI = m + + err = client.SetEphemeralBootSourceByType(ctx, "CD") + _, ok := err.(ErrRedfishClient) + assert.True(t, ok) +} + +func TestSetEphemeralBootSourceByTypeBootSourceUnavailable(t *testing.T) { + m := &redfishMocks.RedfishAPI{} + defer m.AssertExpectations(t) + + ctx, client, err := NewClient("invalid-server", isoPath, redfishURL, false, false, "", "") + assert.NoError(t, err) + + invalidSystem := getTestSystem() + invalidSystem.Boot.BootSourceOverrideTargetRedfishAllowableValues = []redfishClient.BootSource{ + redfishClient.BOOTSOURCE_HDD, + redfishClient.BOOTSOURCE_PXE, + } + + m.On("GetSystem", ctx, client.ephemeralNodeID).Return(invalidSystem, nil, nil) + + // Replace normal API client with mocked API client + client.redfishAPI = m + + err = client.SetEphemeralBootSourceByType(ctx, "Cd") + _, ok := err.(ErrRedfishClient) + assert.True(t, ok) +} + +func TestSetVirtualMediaGetSystemError(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{}, + nil, redfishClient.GenericOpenAPIError{}) + + // Replace normal API client with mocked API client + client.redfishAPI = m + + err = client.SetVirtualMedia(ctx, "CD", client.isoPath) + _, ok := err.(ErrRedfishClient) + assert.True(t, ok) +} + +func TestSetVirtualMediaInsertVirtualMediaError(t *testing.T) { + m := &redfishMocks.RedfishAPI{} + defer m.AssertExpectations(t) + + ctx, client, err := NewClient(systemID, isoPath, redfishURL, false, false, "", "") + assert.NoError(t, err) + + httpResp := &http.Response{StatusCode: 500} + m.On("GetSystem", context.Background(), systemID).Times(1).Return(getTestSystem(), nil, nil) + + realErr := redfishClient.GenericOpenAPIError{} + m.On("InsertVirtualMedia", context.Background(), "manager-1", "Cd", mock.Anything).Return( + redfishClient.RedfishError{}, httpResp, realErr) + + // Replace normal API client with mocked API client + client.redfishAPI = m + + err = client.SetVirtualMedia(ctx, "Cd", client.isoPath) + _, ok := err.(ErrRedfishClient) + assert.True(t, ok) +} diff --git a/pkg/remote/remote_direct.go b/pkg/remote/remote_direct.go index 0c8784d6c..7e55327e0 100644 --- a/pkg/remote/remote_direct.go +++ b/pkg/remote/remote_direct.go @@ -17,7 +17,7 @@ const ( ) // Interface to be implemented by remoteDirect implementation -type Client interface { +type RDClient interface { DoRemoteDirect() error } @@ -26,8 +26,8 @@ func getRemoteDirectClient( remoteConfig *config.RemoteDirect, remoteURL string, username string, - password string) (Client, error) { - var client Client + password string) (RDClient, error) { + var client RDClient switch remoteConfig.RemoteType { case AirshipRemoteTypeRedfish: alog.Debug("Remote type redfish") diff --git a/pkg/remote/types.go b/pkg/remote/types.go new file mode 100644 index 000000000..13ed623e8 --- /dev/null +++ b/pkg/remote/types.go @@ -0,0 +1,32 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package remote + +import ( + "context" +) + +// Client is a set of functions that clients created for out-of-band power management and control should implement. The +// functions within client are used by power management commands and remote direct functionality. +type Client interface { + RebootSystem(context.Context, string) error + EphemeralNodeID() string + + // TODO(drewwalters96): This function may be too tightly coupled to remoteDirect operations. This could probably + // be combined with SetVirtualMedia. + SetEphemeralBootSourceByType(context.Context, string) error + + // TODO(drewwalters96): This function is tightly coupled to Redfish. It should be combined with the + // SetBootSource operation and removed from the client interface. + SetVirtualMedia(context.Context, string, string) error +}