diff --git a/bmc/screenshot.go b/bmc/screenshot.go new file mode 100644 index 00000000..9ab98af6 --- /dev/null +++ b/bmc/screenshot.go @@ -0,0 +1,76 @@ +package bmc + +import ( + "context" + "fmt" + + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" +) + +// ScreenshotGetter interface provides methods to query for a BMC screen capture. +type ScreenshotGetter interface { + Screenshot(ctx context.Context) (image []byte, fileType string, err error) +} + +type screenshotGetterProvider struct { + name string + ScreenshotGetter +} + +// screenshot returns an image capture of the video output. +func screenshot(ctx context.Context, generic []screenshotGetterProvider) (image []byte, fileType string, metadata Metadata, err error) { + var metadataLocal Metadata + + for _, elem := range generic { + if elem.ScreenshotGetter == nil { + continue + } + select { + case <-ctx.Done(): + err = multierror.Append(err, ctx.Err()) + + return image, fileType, metadata, err + default: + metadataLocal.ProvidersAttempted = append(metadataLocal.ProvidersAttempted, elem.name) + image, fileType, vErr := elem.Screenshot(ctx) + if vErr != nil { + err = multierror.Append(err, errors.WithMessagef(vErr, "provider: %v", elem.name)) + continue + + } + metadataLocal.SuccessfulProvider = elem.name + return image, fileType, metadataLocal, nil + } + } + + return image, fileType, metadataLocal, multierror.Append(err, errors.New("failed to capture screenshot")) +} + +// ScreenshotFromInterfaces identifies implementations of the ScreenshotGetter interface and passes the found implementations to the screenshot() wrapper method. +func ScreenshotFromInterfaces(ctx context.Context, generic []interface{}) (image []byte, fileType string, metadata Metadata, err error) { + implementations := make([]screenshotGetterProvider, 0) + for _, elem := range generic { + temp := screenshotGetterProvider{name: getProviderName(elem)} + switch p := elem.(type) { + case ScreenshotGetter: + temp.ScreenshotGetter = p + implementations = append(implementations, temp) + default: + e := fmt.Sprintf("not a ScreenshotGetter implementation: %T", p) + err = multierror.Append(err, errors.New(e)) + } + } + if len(implementations) == 0 { + return image, fileType, metadata, multierror.Append( + err, + errors.Wrap( + bmclibErrs.ErrProviderImplementation, + ("no ScreenshotGetter implementations found"), + ), + ) + } + + return screenshot(ctx, implementations) +} diff --git a/bmc/screenshot_test.go b/bmc/screenshot_test.go new file mode 100644 index 00000000..57d11ce3 --- /dev/null +++ b/bmc/screenshot_test.go @@ -0,0 +1,110 @@ +package bmc + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/hashicorp/go-multierror" + "gopkg.in/go-playground/assert.v1" +) + +type screenshotTester struct { + MakeErrorOut bool +} + +func (r *screenshotTester) Screenshot(ctx context.Context) (img []byte, fileType string, err error) { + if r.MakeErrorOut { + return nil, "", errors.New("crappy bmc is crappy") + } + + return []byte(`foobar`), "png", nil +} + +func (r *screenshotTester) Name() string { + return "test screenshot provider" +} + +func TestScreenshot(t *testing.T) { + testCases := map[string]struct { + makeErrorOut bool + wantImage []byte + wantFileType string + wantSuccessfulProvider string + wantProvidersAttempted []string + wantErr error + ctxTimeout time.Duration + }{ + "success": {false, []byte(`foobar`), "png", "test provider", []string{"test provider"}, nil, 1 * time.Second}, + "error": {true, nil, "", "", []string{"test provider"}, &multierror.Error{Errors: []error{errors.New("provider: test provider: crappy bmc is crappy"), errors.New("failed to capture screenshot")}}, 1 * time.Second}, + "error context timeout": {true, nil, "", "", nil, &multierror.Error{Errors: []error{errors.New("context deadline exceeded")}}, 1 * time.Nanosecond}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + testImplementation := screenshotTester{MakeErrorOut: tc.makeErrorOut} + if tc.ctxTimeout == 0 { + tc.ctxTimeout = time.Second * 3 + } + + ctx, cancel := context.WithTimeout(context.Background(), tc.ctxTimeout) + defer cancel() + image, fileType, metadata, err := screenshot(ctx, []screenshotGetterProvider{{"test provider", &testImplementation}}) + if err != nil { + if tc.wantErr == nil { + t.Fatal(err) + } + + assert.Equal(t, tc.wantErr.Error(), err.Error()) + } else { + assert.Equal(t, tc.wantImage, image) + assert.Equal(t, tc.wantFileType, fileType) + } + + assert.Equal(t, tc.wantProvidersAttempted, metadata.ProvidersAttempted) + assert.Equal(t, tc.wantSuccessfulProvider, metadata.SuccessfulProvider) + }) + } +} + +func TestScreenshotFromInterfaces(t *testing.T) { + testCases := map[string]struct { + wantImage []byte + wantFileType string + wantSuccessfulProvider string + wantProvidersAttempted []string + wantErr error + badImplementation bool + }{ + "success with metadata": {[]byte(`foobar`), "png", "test screenshot provider", []string{"test screenshot provider"}, nil, false}, + "no implementations found": {nil, "", "", nil, &multierror.Error{Errors: []error{errors.New("not a ScreenshotGetter implementation: *struct {}"), errors.New("no ScreenshotGetter implementations found: error in provider implementation")}}, true}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + var generic []interface{} + if tc.badImplementation { + badImplementation := struct{}{} + generic = []interface{}{&badImplementation} + } else { + testImplementation := screenshotTester{} + generic = []interface{}{&testImplementation} + } + image, fileType, metadata, err := ScreenshotFromInterfaces(context.Background(), generic) + if err != nil { + if tc.wantErr == nil { + t.Fatal(err) + } + + assert.Equal(t, tc.wantErr.Error(), err.Error()) + } else { + assert.Equal(t, tc.wantImage, image) + assert.Equal(t, tc.wantFileType, fileType) + } + + assert.Equal(t, tc.wantProvidersAttempted, metadata.ProvidersAttempted) + assert.Equal(t, tc.wantSuccessfulProvider, metadata.SuccessfulProvider) + }) + } +} diff --git a/client.go b/client.go index c120c604..61b130e3 100644 --- a/client.go +++ b/client.go @@ -12,6 +12,7 @@ import ( "github.com/bmc-toolbox/bmclib/v2/bmc" "github.com/bmc-toolbox/bmclib/v2/internal/httpclient" "github.com/bmc-toolbox/bmclib/v2/providers/asrockrack" + "github.com/bmc-toolbox/bmclib/v2/providers/dell" "github.com/bmc-toolbox/bmclib/v2/providers/intelamt" "github.com/bmc-toolbox/bmclib/v2/providers/ipmitool" "github.com/bmc-toolbox/bmclib/v2/providers/redfish" @@ -54,6 +55,7 @@ type providerConfig struct { asrock asrockrack.Config gofish redfish.Config intelamt intelamt.Config + dell dell.Config } // NewClient returns a new Client struct @@ -79,6 +81,10 @@ func NewClient(host, user, pass string, opts ...Option) *Client { HostScheme: "http", Port: 16992, }, + dell: dell.Config{ + Port: "443", + VersionsNotCompatible: []string{}, + }, }, } @@ -158,6 +164,18 @@ func (c *Client) registerProviders() { } driverAMT := intelamt.New(c.Auth.Host, c.Auth.User, c.Auth.Pass, iamtOpts...) c.Registry.Register(intelamt.ProviderName, intelamt.ProviderProtocol, intelamt.Features, nil, driverAMT) + + // register Dell gofish provider + dellGofishHttpClient := *c.httpClient + //dellGofishHttpClient.Transport = c.httpClient.Transport.(*http.Transport).Clone() + dellGofishOpts := []dell.Option{ + dell.WithHttpClient(&dellGofishHttpClient), + dell.WithVersionsNotCompatible(c.providerConfig.dell.VersionsNotCompatible), + dell.WithUseBasicAuth(c.providerConfig.dell.UseBasicAuth), + dell.WithPort(c.providerConfig.dell.Port), + } + driverGoFishDell := dell.New(c.Auth.Host, c.Auth.User, c.Auth.Pass, c.Logger, dellGofishOpts...) + c.Registry.Register(dell.ProviderName, redfish.ProviderProtocol, dell.Features, nil, driverGoFishDell) } // GetMetadata returns the metadata that is populated after each BMC function/method call @@ -342,3 +360,10 @@ func (c *Client) PostCode(ctx context.Context) (status string, code int, err err c.setMetadata(metadata) return status, code, err } + +func (c *Client) Screenshot(ctx context.Context) (image []byte, fileType string, err error) { + image, fileType, metadata, err := bmc.ScreenshotFromInterfaces(ctx, c.registry().GetDriverInterfaces()) + c.setMetadata(metadata) + + return image, fileType, err +} diff --git a/errors/errors.go b/errors/errors.go index d788776b..0d56bb1b 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -89,6 +89,9 @@ var ( // ErrNoBiosAttributes is returned when no bios attributes are available from the BMC. ErrNoBiosAttributes = errors.New("no BIOS attributes available") + + // ErrScreenshot is returned when screen capture fails. + ErrScreenshot = errors.New("error in capturing screen") ) type ErrUnsupportedHardware struct { diff --git a/examples/screenshot/doc.go b/examples/screenshot/doc.go new file mode 100644 index 00000000..f7e36f8b --- /dev/null +++ b/examples/screenshot/doc.go @@ -0,0 +1,20 @@ +/* +status is an example commmand that utilizes the 'v1' bmclib interface methods +to capture a screenshot. + + $ go run ./examples/v1/status/main.go -h + Usage of /tmp/go-build1941100323/b001/exe/main: + -cert-pool string + Path to an file containing x509 CAs. An empty string uses the system CAs. Only takes effect when --secure-tls=true + -host string + BMC hostname to connect to + -password string + Username to login with + -port int + BMC port to connect to (default 443) + -secure-tls + Enable secure TLS + -user string + Username to login with +*/ +package main diff --git a/examples/screenshot/main.go b/examples/screenshot/main.go new file mode 100644 index 00000000..dff9bfd1 --- /dev/null +++ b/examples/screenshot/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "crypto/x509" + "flag" + "fmt" + "io/ioutil" + "os" + "time" + + "github.com/bmc-toolbox/bmclib/v2" + "github.com/bmc-toolbox/bmclib/v2/providers" + "github.com/bombsimon/logrusr/v2" + "github.com/sirupsen/logrus" +) + +func main() { + user := flag.String("user", "", "Username to login with") + pass := flag.String("password", "", "Username to login with") + host := flag.String("host", "", "BMC hostname to connect to") + port := flag.String("port", "443", "BMC port to connect to") + withSecureTLS := flag.Bool("secure-tls", false, "Enable secure TLS") + certPoolFile := flag.String("cert-pool", "", "Path to an file containing x509 CAs. An empty string uses the system CAs. Only takes effect when --secure-tls=true") + flag.Parse() + + l := logrus.New() + l.Level = logrus.DebugLevel + logger := logrusr.New(l) + + if *host == "" || *user == "" || *pass == "" { + l.Fatal("required host/user/pass parameters not defined") + } + + clientOpts := []bmclib.Option{ + bmclib.WithLogger(logger), + bmclib.WithRedfishPort(*port), + } + + if *withSecureTLS { + var pool *x509.CertPool + if *certPoolFile != "" { + pool = x509.NewCertPool() + data, err := ioutil.ReadFile(*certPoolFile) + if err != nil { + l.Fatal(err) + } + pool.AppendCertsFromPEM(data) + } + // a nil pool uses the system certs + clientOpts = append(clientOpts, bmclib.WithSecureTLS(pool)) + } + + cl := bmclib.NewClient(*host, *user, *pass, clientOpts...) + cl.Registry.Drivers = cl.Registry.Supports(providers.FeatureScreenshot) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + err := cl.Open(ctx) + if err != nil { + l.WithError(err).Fatal(err, "BMC login failed") + + return + } + defer cl.Close(ctx) + + image, fileType, err := cl.Screenshot(ctx) + if err != nil { + l.WithError(err).Error() + + return + } + + filename := fmt.Sprintf("screenshot." + fileType) + fh, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + l.WithError(err).Error() + + return + } + + defer fh.Close() + + _, err = fh.Write(image) + if err != nil { + l.WithError(err).Error() + + return + } + + l.Info("screenshot saved as: " + filename) +} diff --git a/internal/redfishwrapper/client.go b/internal/redfishwrapper/client.go index c969e30a..51a899b3 100644 --- a/internal/redfishwrapper/client.go +++ b/internal/redfishwrapper/client.go @@ -90,6 +90,7 @@ func (c *Client) Open(ctx context.Context) error { if c.port != "" { endpoint = c.host + ":" + c.port } + config := gofish.ClientConfig{ Endpoint: endpoint, Username: c.user, @@ -189,3 +190,11 @@ func (c *Client) VersionCompatible() bool { return !slices.Contains(c.versionsNotCompatible, c.client.Service.RedfishVersion) } + +func (c *Client) PostWithHeaders(ctx context.Context, url string, payload interface{}, headers map[string]string) (*http.Response, error) { + return c.client.PostWithHeaders(url, payload, headers) +} + +func (c *Client) PatchWithHeaders(ctx context.Context, url string, payload interface{}, headers map[string]string) (*http.Response, error) { + return c.client.PatchWithHeaders(url, payload, headers) +} diff --git a/option.go b/option.go index 1e8c3599..abdec1fb 100644 --- a/option.go +++ b/option.go @@ -121,3 +121,19 @@ func WithIntelAMTPort(port uint32) Option { args.providerConfig.intelamt.Port = port } } + +// WithDellRedfishVersionsNotCompatible sets the list of incompatible redfish versions. +// +// With this option set, The bmclib.Registry.FilterForCompatible(ctx) method will not proceed on +// devices with the given redfish version(s). +func WithDellRedfishVersionsNotCompatible(versions []string) Option { + return func(args *Client) { + args.providerConfig.dell.VersionsNotCompatible = append(args.providerConfig.dell.VersionsNotCompatible, versions...) + } +} + +func WithDellRedfishUseBasicAuth(useBasicAuth bool) Option { + return func(args *Client) { + args.providerConfig.dell.UseBasicAuth = useBasicAuth + } +} diff --git a/providers/dell/fixtures/serviceroot.json b/providers/dell/fixtures/serviceroot.json new file mode 100644 index 00000000..4bd38c6f --- /dev/null +++ b/providers/dell/fixtures/serviceroot.json @@ -0,0 +1,80 @@ +{ + "@odata.context": "/redfish/v1/$metadata#ServiceRoot.ServiceRoot", + "@odata.id": "/redfish/v1", + "@odata.type": "#ServiceRoot.v1_6_0.ServiceRoot", + "AccountService": { + "@odata.id": "/redfish/v1/AccountService" + }, + "CertificateService": { + "@odata.id": "/redfish/v1/CertificateService" + }, + "Chassis": { + "@odata.id": "/redfish/v1/Chassis" + }, + "Description": "Root Service", + "EventService": { + "@odata.id": "/redfish/v1/EventService" + }, + "Fabrics": { + "@odata.id": "/redfish/v1/Fabrics" + }, + "Id": "RootService", + "JobService": { + "@odata.id": "/redfish/v1/JobService" + }, + "JsonSchemas": { + "@odata.id": "/redfish/v1/JsonSchemas" + }, + "Links": { + "Sessions": { + "@odata.id": "/redfish/v1/SessionService/Sessions" + } + }, + "Managers": { + "@odata.id": "/redfish/v1/Managers" + }, + "Name": "Root Service", + "Oem": { + "Dell": { + "@odata.context": "/redfish/v1/$metadata#DellServiceRoot.DellServiceRoot", + "@odata.type": "#DellServiceRoot.v1_0_0.DellServiceRoot", + "IsBranded": 0, + "ManagerMACAddress": "d0:8e:79:bb:3e:ea", + "ServiceTag": "FOOBAR" + } + }, + "Product": "Integrated Dell Remote Access Controller", + "ProtocolFeaturesSupported": { + "ExcerptQuery": false, + "ExpandQuery": { + "ExpandAll": true, + "Levels": true, + "Links": true, + "MaxLevels": 1, + "NoLinks": true + }, + "FilterQuery": true, + "OnlyMemberQuery": true, + "SelectQuery": true + }, + "RedfishVersion": "1.9.0", + "Registries": { + "@odata.id": "/redfish/v1/Registries" + }, + "SessionService": { + "@odata.id": "/redfish/v1/SessionService" + }, + "Systems": { + "@odata.id": "/redfish/v1/Systems" + }, + "Tasks": { + "@odata.id": "/redfish/v1/TaskService" + }, + "TelemetryService": { + "@odata.id": "/redfish/v1/TelemetryService" + }, + "UpdateService": { + "@odata.id": "/redfish/v1/UpdateService" + }, + "Vendor": "Dell" +} \ No newline at end of file diff --git a/providers/dell/idrac.go b/providers/dell/idrac.go new file mode 100644 index 00000000..cb7024d0 --- /dev/null +++ b/providers/dell/idrac.go @@ -0,0 +1,210 @@ +package dell + +import ( + "context" + "crypto/x509" + "encoding/base64" + "encoding/json" + "io" + "net/http" + + "github.com/bmc-toolbox/bmclib/v2/internal/httpclient" + "github.com/bmc-toolbox/bmclib/v2/internal/redfishwrapper" + "github.com/bmc-toolbox/bmclib/v2/providers" + "github.com/go-logr/logr" + "github.com/jacobweinstock/registrar" + "github.com/pkg/errors" + + bmclibErrs "github.com/bmc-toolbox/bmclib/v2/errors" +) + +const ( + // ProviderName for the provider Dell implementation + ProviderName = "dell" + // ProviderProtocol for the provider Dell implementation + ProviderProtocol = "redfish" + + redfishV1Prefix = "/redfish/v1" + screenshotEndpoint = "/Dell/Managers/iDRAC.Embedded.1/DellLCService/Actions/DellLCService.ExportServerScreenShot" + managerAttributesEndpoint = "/Managers/iDRAC.Embedded.1/Attributes" +) + +var ( + // Features implemented by dell redfish + Features = registrar.Features{ + providers.FeatureScreenshot, + } +) + +type Config struct { + HttpClient *http.Client + Port string + // VersionsNotCompatible is the list of incompatible redfish versions. + // + // With this option set, The bmclib.Registry.FilterForCompatible(ctx) method will not proceed on + // devices with the given redfish version(s). + VersionsNotCompatible []string + RootCAs *x509.CertPool + UseBasicAuth bool +} + +// Option for setting optional Client values +type Option func(*Config) + +func WithHttpClient(httpClient *http.Client) Option { + return func(c *Config) { + c.HttpClient = httpClient + } +} + +func WithPort(port string) Option { + return func(c *Config) { + c.Port = port + } +} + +func WithVersionsNotCompatible(versionsNotCompatible []string) Option { + return func(c *Config) { + c.VersionsNotCompatible = versionsNotCompatible + } +} + +func WithRootCAs(rootCAs *x509.CertPool) Option { + return func(c *Config) { + c.RootCAs = rootCAs + } +} + +func WithUseBasicAuth(useBasicAuth bool) Option { + return func(c *Config) { + c.UseBasicAuth = useBasicAuth + } +} + +// Conn details for redfish client +type Conn struct { + redfishwrapper *redfishwrapper.Client + Log logr.Logger +} + +// New returns connection with a redfish client initialized +func New(host, user, pass string, log logr.Logger, opts ...Option) *Conn { + defaultConfig := &Config{ + HttpClient: httpclient.Build(), + Port: "443", + VersionsNotCompatible: []string{}, + } + + for _, opt := range opts { + opt(defaultConfig) + } + + rfOpts := []redfishwrapper.Option{ + redfishwrapper.WithHTTPClient(defaultConfig.HttpClient), + redfishwrapper.WithVersionsNotCompatible(defaultConfig.VersionsNotCompatible), + } + + if defaultConfig.RootCAs != nil { + rfOpts = append(rfOpts, redfishwrapper.WithSecureTLS(defaultConfig.RootCAs)) + } + + return &Conn{ + Log: log, + redfishwrapper: redfishwrapper.NewClient(host, defaultConfig.Port, user, pass, rfOpts...), + } +} + +// Open a connection to a BMC via redfish +func (c *Conn) Open(ctx context.Context) (err error) { + return c.redfishwrapper.Open(ctx) +} + +// Close a connection to a BMC via redfish +func (c *Conn) Close(ctx context.Context) error { + return c.redfishwrapper.Close(ctx) +} + +// Name returns the client provider name. +func (c *Conn) Name() string { + return ProviderName +} + +// Compatible tests whether a BMC is compatible with the gofish provider +func (c *Conn) Compatible(ctx context.Context) bool { + err := c.Open(ctx) + if err != nil { + c.Log.V(2).WithValues( + "provider", + c.Name(), + ).Info("warn", bmclibErrs.ErrCompatibilityCheck.Error(), err.Error()) + + return false + } + defer c.Close(ctx) + + if !c.redfishwrapper.VersionCompatible() { + c.Log.V(2).WithValues( + "provider", + c.Name(), + ).Info("info", bmclibErrs.ErrCompatibilityCheck.Error(), "incompatible redfish version") + + return false + } + + _, err = c.PowerStateGet(ctx) + if err != nil { + c.Log.V(2).WithValues( + "provider", + c.Name(), + ).Info("warn", bmclibErrs.ErrCompatibilityCheck.Error(), err.Error()) + } + + return err == nil +} + +// PowerStateGet gets the power state of a BMC machine +func (c *Conn) PowerStateGet(ctx context.Context) (state string, err error) { + return c.redfishwrapper.SystemPowerStatus(ctx) +} + +func (c *Conn) Screenshot(ctx context.Context) (image []byte, fileType string, err error) { + fileType = "png" + + resp, err := c.redfishwrapper.PostWithHeaders( + ctx, + redfishV1Prefix+screenshotEndpoint, + // other FileType parameters are LastCrashScreenshot, Preview + json.RawMessage(`{"FileType":"ServerScreenShot"}`), + map[string]string{"Content-Type": "application/json"}, + ) + if err != nil { + return nil, "", errors.Wrap(bmclibErrs.ErrScreenshot, err.Error()) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", errors.Wrap(bmclibErrs.ErrScreenshot, err.Error()) + } + + if resp.StatusCode != 200 { + return nil, "", errors.Wrap(bmclibErrs.ErrScreenshot, resp.Status) + } + + data := &struct { + B64encoded string `json:"ServerScreenshotFile"` + }{} + if err := json.Unmarshal(body, &data); err != nil { + return nil, "", errors.Wrap(bmclibErrs.ErrScreenshot, err.Error()) + } + + if data.B64encoded == "" { + return nil, "", errors.Wrap(bmclibErrs.ErrScreenshot, "no screencapture data in response") + } + + image, err = base64.StdEncoding.DecodeString(data.B64encoded) + if err != nil { + return nil, "", errors.Wrap(bmclibErrs.ErrScreenshot, err.Error()) + } + + return image, fileType, nil +} diff --git a/providers/dell/idrac_test.go b/providers/dell/idrac_test.go new file mode 100644 index 00000000..b9cfbbdb --- /dev/null +++ b/providers/dell/idrac_test.go @@ -0,0 +1,110 @@ +package dell + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" +) + +const ( + fixturesDir = "./fixtures" +) + +func serviceRoot(w http.ResponseWriter, r *http.Request) { + // expect either GET or Delete methods + if r.Method != http.MethodGet && r.Method != http.MethodDelete { + w.WriteHeader(http.StatusNotFound) + } + fixture := fixturesDir + "/serviceroot.json" + fh, err := os.Open(fixture) + if err != nil { + log.Fatal(err) + } + + defer fh.Close() + + b, err := io.ReadAll(fh) + if err != nil { + log.Fatal(err) + } + + _, _ = w.Write(b) +} + +func Test_Screenshot(t *testing.T) { + // byte slice instead of a real image + img := []byte(`foobar`) + + testcases := []struct { + name string + imgbytes []byte + handler func(http.ResponseWriter, *http.Request) + }{ + { + "happy path", + []byte(`foobar`), + func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodPost) + + assert.Equal(t, r.Header.Get("Content-Type"), "application/json") + + b, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, []byte(`{"FileType":"ServerScreenShot"}`), b) + + encoded := base64.RawStdEncoding.EncodeToString(img) + respFmtStr := `{"@Message.ExtendedInfo":[{"Message":"Successfully Completed Request","MessageArgs":[],"MessageArgs@odata.count":0,"MessageId":"Base.1.8.Success","RelatedProperties":[],"RelatedProperties@odata.count":0,"Resolution":"None","Severity":"OK"},{"Message":"The Export Server Screen Shot operation successfully exported the server screen shot file.","MessageArgs":[],"MessageArgs@odata.count":0,"MessageId":"IDRAC.2.5.LC080","RelatedProperties":[],"RelatedProperties@odata.count":0,"Resolution":"Download the encoded Base64 format server screen shot file, decode the Base64 file and then save it as a *.png file.","Severity":"Informational"}],"ServerScreenshotFile":"%s"}` + + _, _ = w.Write([]byte(fmt.Sprintf(respFmtStr, encoded))) + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mux := http.NewServeMux() + // default redfish handler + // mux.HandleFunc("/redfish/v1/SessionService/Sessions", sessionService) + mux.HandleFunc("/redfish/v1/", serviceRoot) + + // screenshot handler + mux.HandleFunc(redfishV1Prefix+screenshotEndpoint, tc.handler) + + server := httptest.NewTLSServer(mux) + defer server.Close() + + parsedURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + // os.Setenv("DEBUG_BMCLIB", "true") + client := New(parsedURL.Hostname(), "", "", logr.Discard(), WithPort(parsedURL.Port())) + + err = client.Open(context.TODO()) + if err != nil { + t.Fatal(err) + } + + img, fileType, err := client.Screenshot(context.TODO()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, tc.imgbytes, img) + assert.Equal(t, "png", fileType) + }) + } +} diff --git a/providers/providers.go b/providers/providers.go index 4ae23fe6..64c587a4 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -29,6 +29,8 @@ const ( FeatureFirmwareInstallStatus registrar.Feature = "firmwareinstallstatus" // FeatureInventoryRead means an implementation that returns the hardware and firmware inventory FeatureInventoryRead registrar.Feature = "inventoryread" - // FeaturePostCodeRead means an implmentation that returns the boot BIOS/UEFI post code status and value + // FeaturePostCodeRead means an implementation that returns the boot BIOS/UEFI post code status and value FeaturePostCodeRead registrar.Feature = "postcoderead" + // FeatureScreenshot means an implementation that returns a screenshot of the video. + FeatureScreenshot registrar.Feature = "screenshot" )