From 566b6824994bb0eee48061c91d0601ca2c500df5 Mon Sep 17 00:00:00 2001 From: Mike Johanson Date: Wed, 17 Sep 2025 14:23:34 -0700 Subject: [PATCH] feat: adds alpha-stage /redfish endpoint for power action This is an exploratory concept as a stop gap for vPRO platforms not being redfish capable. Any implementation or usage of these endpoints are strictly for proof-of-concept testing and feedback. These are not production endpoints, may never be promoted to release status and may be removed at any time. --- internal/controller/http/redfish/root.go | 118 +++++++++++++ internal/controller/http/redfish/systems.go | 158 ++++++++++++++++++ internal/controller/http/router.go | 8 + .../controller/http/v1/devicemanagement.go | 2 + internal/controller/http/v1/power.go | 38 +++++ 5 files changed, 324 insertions(+) create mode 100644 internal/controller/http/redfish/root.go create mode 100644 internal/controller/http/redfish/systems.go diff --git a/internal/controller/http/redfish/root.go b/internal/controller/http/redfish/root.go new file mode 100644 index 00000000..477f3bbc --- /dev/null +++ b/internal/controller/http/redfish/root.go @@ -0,0 +1,118 @@ +package redfish + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/device-management-toolkit/console/pkg/logger" +) + +// NewRoutes registers the Redfish Service Root handler. +// It responds to GET on the group base path (e.g., /redfish/v1). +func NewRoutes(r *gin.RouterGroup, l logger.Interface) { + // Service Root per DMTF Redfish spec. Keep minimal and compliant. + handler := func(c *gin.Context) { + // Minimal ServiceRoot payload. Additional links can be added later. + payload := map[string]any{ + "@odata.type": "#ServiceRoot.v1_0_0.ServiceRoot", + "@odata.id": "/redfish/v1/", + "Id": "RootService", + "Name": "Redfish Service Root", + "RedfishVersion": "1.0.0", + "SessionService": map[string]any{ + "@odata.id": "/redfish/v1/SessionService", + }, + "Systems": map[string]any{ + "@odata.id": "/redfish/v1/Systems", + }, + "Links": map[string]any{ + "Sessions": map[string]any{ + "@odata.id": "/redfish/v1/SessionService/Sessions", + }, + }, + } + + c.JSON(http.StatusOK, payload) + } + + // Register for both the group root and explicit trailing slash. + r.GET("", handler) + r.GET("/", handler) + + // $metadata endpoint (minimal OData metadata document) + r.GET("/$metadata", func(c *gin.Context) { + c.Header("Content-Type", "application/xml; charset=utf-8") + c.String(http.StatusOK, ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`) + }) + + // SessionService endpoint + r.GET("/SessionService", func(c *gin.Context) { + payload := map[string]any{ + "@odata.type": "#SessionService.v1_0_0.SessionService", + "@odata.id": "/redfish/v1/SessionService", + "Id": "SessionService", + "Name": "Redfish Session Service", + "ServiceEnabled": true, + "SessionTimeout": 30, + "Sessions": map[string]any{"@odata.id": "/redfish/v1/SessionService/Sessions"}, + } + + c.JSON(http.StatusOK, payload) + }) + + // Sessions collection endpoint (read-only, empty list for now) + r.GET("/SessionService/Sessions", func(c *gin.Context) { + payload := map[string]any{ + "@odata.type": "#SessionCollection.SessionCollection", + "@odata.id": "/redfish/v1/SessionService/Sessions", + "Name": "Session Collection", + "Members@odata.count": 0, + "Members": []any{}, + } + + c.JSON(http.StatusOK, payload) + }) + + l.Info("Registered Redfish Service Root at %s", r.BasePath()) +} diff --git a/internal/controller/http/redfish/systems.go b/internal/controller/http/redfish/systems.go new file mode 100644 index 00000000..2163845b --- /dev/null +++ b/internal/controller/http/redfish/systems.go @@ -0,0 +1,158 @@ +package redfish + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/device-management-toolkit/console/internal/usecase/devices" + "github.com/device-management-toolkit/console/pkg/logger" +) + +// Lint constants +const ( + maxSystemsList = 100 + powerStateUnknown = "Unknown" + powerStateOn = "On" + powerStateOff = "Off" + resetTypeOn = "On" + resetTypeForceOff = "ForceOff" + resetTypeForceRestart = "ForceRestart" + resetTypePowerCycle = "PowerCycle" + actionPowerUp = 2 + actionPowerCycle = 5 + actionPowerDown = 8 + actionReset = 10 + // CIM PowerState enum values (Device.PowerState) + cimPowerOn = 2 + cimPowerSleep = 3 + cimPowerStandby = 4 + cimPowerSoftOff = 7 + cimPowerHardOff = 8 +) + +// NewSystemsRoutes registers minimal Redfish ComputerSystem routes. +// It exposes: +// - GET /redfish/v1/Systems +// - GET /redfish/v1/Systems/:id +// - POST /redfish/v1/Systems/:id/Actions/ComputerSystem.Reset +// The :id is expected to be the device GUID and will be mapped directly to SendPowerAction. +func NewSystemsRoutes(r *gin.RouterGroup, d devices.Feature, l logger.Interface) { + systems := r.Group("/Systems") + systems.GET("", getSystemsCollectionHandler(d, l)) + systems.GET(":id", getSystemInstanceHandler(d, l)) + systems.POST(":id/Actions/ComputerSystem.Reset", postSystemResetHandler(d, l)) + l.Info("Registered Redfish Systems routes under %s", r.BasePath()+"/Systems") +} + +func getSystemsCollectionHandler(d devices.Feature, l logger.Interface) gin.HandlerFunc { + return func(c *gin.Context) { + items, err := d.Get(c.Request.Context(), maxSystemsList, 0, "") + if err != nil { + l.Error(err, "http - redfish - Systems collection") + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + + return + } + + members := make([]any, 0, len(items)) + for i := range items { // avoid value copy + it := &items[i] + if it.GUID == "" { + continue + } + + members = append(members, map[string]any{ + "@odata.id": "/redfish/v1/Systems/" + it.GUID, + }) + } + + payload := map[string]any{ + "@odata.type": "#ComputerSystemCollection.ComputerSystemCollection", + "@odata.id": "/redfish/v1/Systems", + "Name": "Computer System Collection", + "Members@odata.count": len(members), + "Members": members, + } + c.JSON(http.StatusOK, payload) + } +} + +func getSystemInstanceHandler(d devices.Feature, l logger.Interface) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + powerState := powerStateUnknown + + if ps, err := d.GetPowerState(c.Request.Context(), id); err != nil { + l.Warn("redfish - Systems instance: failed to get power state for %s: %v", id, err) + } else { + switch ps.PowerState { // CIM PowerState values + case actionPowerUp: // 2 (On) + powerState = powerStateOn + case cimPowerSleep, cimPowerStandby: // Sleep/Standby -> treat as On + powerState = powerStateOn + case cimPowerSoftOff, cimPowerHardOff: // Soft Off / Hard Off + powerState = powerStateOff + default: + powerState = powerStateUnknown + } + } + + payload := map[string]any{ + "@odata.type": "#ComputerSystem.v1_0_0.ComputerSystem", + "@odata.id": "/redfish/v1/Systems/" + id, + "Id": id, + "Name": "Computer System " + id, + "PowerState": powerState, + "Actions": map[string]any{ + "#ComputerSystem.Reset": map[string]any{ + "target": "/redfish/v1/Systems/" + id + "/Actions/ComputerSystem.Reset", + "ResetType@Redfish.AllowableValues": []string{resetTypeOn, resetTypeForceOff, resetTypeForceRestart, resetTypePowerCycle}, + }, + }, + } + c.JSON(http.StatusOK, payload) + } +} + +func postSystemResetHandler(d devices.Feature, l logger.Interface) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + + var body struct { + ResetType string `json:"ResetType"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + + return + } + + var action int + + switch body.ResetType { + case resetTypeOn: + action = actionPowerUp + case resetTypeForceOff: + action = actionPowerDown + case resetTypeForceRestart: + action = actionReset + case resetTypePowerCycle: + action = actionPowerCycle + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported ResetType"}) + + return + } + + res, err := d.SendPowerAction(c.Request.Context(), id, action) + if err != nil { + l.Error(err, "http - redfish - ComputerSystem.Reset") + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + + return + } + + c.JSON(http.StatusOK, res) + } +} diff --git a/internal/controller/http/router.go b/internal/controller/http/router.go index 4cce9249..3c36256b 100644 --- a/internal/controller/http/router.go +++ b/internal/controller/http/router.go @@ -16,6 +16,7 @@ import ( ginSwagger "github.com/swaggo/gin-swagger" "github.com/device-management-toolkit/console/config" + "github.com/device-management-toolkit/console/internal/controller/http/redfish" v1 "github.com/device-management-toolkit/console/internal/controller/http/v1" v2 "github.com/device-management-toolkit/console/internal/controller/http/v2" "github.com/device-management-toolkit/console/internal/usecase" @@ -99,6 +100,13 @@ func NewRouter(handler *gin.Engine, l logger.Interface, t usecase.Usecases, cfg v1.NewAmtRoutes(h2, t.Devices, t.AMTExplorer, t.Exporter, l) } + bluefish := protected.Group("/redfish/v1") + { + // Redfish Service Root and minimal services + redfish.NewRoutes(bluefish, l) + redfish.NewSystemsRoutes(bluefish, t.Devices, l) + } + h := protected.Group("/v1/admin") { v1.NewDomainRoutes(h, t.Domains, l) diff --git a/internal/controller/http/v1/devicemanagement.go b/internal/controller/http/v1/devicemanagement.go index 8c37435c..7e4ba0b0 100644 --- a/internal/controller/http/v1/devicemanagement.go +++ b/internal/controller/http/v1/devicemanagement.go @@ -34,6 +34,8 @@ func NewAmtRoutes(handler *gin.RouterGroup, d devices.Feature, amt amtexplorer.F h.GET("diskInfo/:guid", r.getDiskInfo) h.GET("power/state/:guid", r.getPowerState) h.POST("power/action/:guid", r.powerAction) + h.POST("power/on/:guid", r.powerOn) + h.POST("power/off/:guid", r.powerOff) h.POST("power/bootOptions/:guid", r.setBootOptions) h.POST("power/bootoptions/:guid", r.setBootOptions) h.GET("power/capabilities/:guid", r.getPowerCapabilities) diff --git a/internal/controller/http/v1/power.go b/internal/controller/http/v1/power.go index 7eb465c3..82540fe6 100644 --- a/internal/controller/http/v1/power.go +++ b/internal/controller/http/v1/power.go @@ -8,6 +8,12 @@ import ( "github.com/device-management-toolkit/console/internal/entity/dto/v1" ) +// Reuse the same action codes used in Redfish systems +const ( + actionPowerUp = 2 + actionPowerDown = 8 +) + func (r *deviceManagementRoutes) getPowerState(c *gin.Context) { guid := c.Param("guid") @@ -57,6 +63,38 @@ func (r *deviceManagementRoutes) powerAction(c *gin.Context) { c.JSON(http.StatusOK, response) } +// powerOn maps POST /amt/power/on/:guid to SendPowerAction with action=2 (Power Up) +func (r *deviceManagementRoutes) powerOn(c *gin.Context) { + guid := c.Param("guid") + + // Power Up + response, err := r.d.SendPowerAction(c.Request.Context(), guid, actionPowerUp) + if err != nil { + r.l.Error(err, "http - v1 - powerOn") + ErrorResponse(c, err) + + return + } + + c.JSON(http.StatusOK, response) +} + +// powerOff maps POST /amt/power/off/:guid to SendPowerAction with action=8 (Power Down) +func (r *deviceManagementRoutes) powerOff(c *gin.Context) { + guid := c.Param("guid") + + // Power Down + response, err := r.d.SendPowerAction(c.Request.Context(), guid, actionPowerDown) + if err != nil { + r.l.Error(err, "http - v1 - powerOff") + ErrorResponse(c, err) + + return + } + + c.JSON(http.StatusOK, response) +} + func (r *deviceManagementRoutes) setBootOptions(c *gin.Context) { guid := c.Param("guid")