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