Redfish vendor profiles from public documentation
One DMTF Redfish implementation, plus a profile per vendor with what its documentation (or Ironic/sushy, MAAS, bmclib field notes) says is different, each with sources: Dell iDRAC, HPE iLO, Lenovo XCC, Supermicro, xFusion/Huawei iBMC, KAYTUS/Inspur, Fujitsu iRMC, Cisco CIMC, GIGABYTE, ASRock Rack, OpenBMC, AMI MegaRAC. Generic rules: reset types and boot targets from AllowableValues or ActionInfo; boot target, enable and mode in one PATCH, the mode kept, omitted or tried per profile; If-Match from header or @odata.etag with 412 (strong, then none) and 428 handling; AMI future-state /SD and @Redfish.Settings targets; busy retries (POST, 409, 503, "Retry in"); 202 task following; chassis reset fallback; emulated power cycle; session login; 400 answers that say Success. Probe results (reset types, boot targets, mode, profile) are kept on the BMC record and shown in the UI; the UI's driver list comes from the server. Each profile has a fake BMC test built from its sources. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a549f01ef5
commit
3568c4866e
6 files changed
+1293
-297
No files matched your search
@@ -14,6 +14,16 @@ publish a tag that has no section here.
|
||||
the PowerDNS API (ownership by RRset comment) or RFC 2136 dynamic update with TSIG (ownership by a
|
||||
`_rollcall.<name>` TXT marker and update prerequisites, so foreign records are never overwritten;
|
||||
drift is detected by querying). `rollcall dns`, `rollcall dns sync [-dry]`, a doctor check.
|
||||
- Redfish vendor profiles: one standard driver plus per-vendor behaviour from public documentation,
|
||||
for Dell iDRAC, HPE iLO, Lenovo XCC, Supermicro, xFusion/Huawei iBMC, KAYTUS/Inspur, Fujitsu iRMC,
|
||||
Cisco CIMC, GIGABYTE, ASRock Rack, OpenBMC and AMI MegaRAC.
|
||||
- Boot mode kept, omitted or tried per vendor.
|
||||
- If-Match with 412/428 handling; future-state (`/SD`) and `@Redfish.Settings` targets.
|
||||
- Busy retries and task following.
|
||||
- Chassis reset fallback and an emulated power cycle.
|
||||
- Session login (`auth=session`).
|
||||
- Probe results (reset types, boot targets, boot mode, profile) are kept on the BMC and shown in
|
||||
the UI, and the UI's driver list comes from the server.
|
||||
|
||||
### Security
|
||||
A review of the whole code base; the fixes below. Upgrading: rerun `deploy/install.sh` on the
|
||||
|
||||
@@ -498,13 +498,64 @@ that reaches the BMC network.
|
||||
| Driver | For |
|
||||
|---|---|
|
||||
| `ipmi` | IPMI 2.0 over LAN (ipmitool lanplus), any vendor; settings `cipher` (17 on newer BMCs), `privilege`, `interface` |
|
||||
| `redfish` | DMTF Redfish, any vendor: system discovered, ResetType chosen from what the BMC allows, boot override once (with If-Match where the BMC wants it) |
|
||||
| `idrac` | Dell iDRAC 8/9 (Redfish, `System.Embedded.1`) |
|
||||
| `ilo` | HPE iLO 4 (2.30+), 5, 6 (Redfish; no PowerCycle, so ForceRestart; PushPowerButton for soft off on older firmware) |
|
||||
| `supermicro`, `xcc`, `openbmc` | Supermicro, Lenovo XClarity, OpenBMC (Redfish) |
|
||||
| `redfish` and the vendor drivers below | DMTF Redfish (see below) |
|
||||
| `proxmox` | Proxmox VE VMs: API token (user = token id, password = secret), settings `node`, `vmid` |
|
||||
| `manual` | no power control; actions say so |
|
||||
|
||||
### Redfish across vendors
|
||||
|
||||
There is one Redfish implementation of the DMTF standard. These rules hold for
|
||||
every vendor:
|
||||
|
||||
- **Reset types and boot targets.** Read from what the BMC advertises
|
||||
(`@Redfish.AllowableValues` or the action's ActionInfo), never assumed.
|
||||
- **Boot override.** Target and enable go in one PATCH.
|
||||
- **If-Match.** Sent with the resource's ETag (header or `@odata.etag`). On
|
||||
412 it is retried with the strong form, then without; on 428 it is fetched
|
||||
first.
|
||||
- **Pending settings.** A BMC that keeps them elsewhere (`.../SD`,
|
||||
`@Redfish.Settings`) gets the same PATCH there.
|
||||
- **Busy answers.** Retried for up to a minute: the server is in POST, 409
|
||||
right after a power change, 503, "Retry in N seconds".
|
||||
- **202 answers.** Followed to their task monitor.
|
||||
- **Reset on the chassis.** Used when the system has no reset of its own.
|
||||
- **Missing power cycle.** ForceOff, wait for Off, then On.
|
||||
- **Paths from the BMC.** Only followed if they stay on the BMC and under
|
||||
`/redfish/`.
|
||||
|
||||
What a vendor documents differently is a profile in
|
||||
`cmd/rollcall/redfish.go`, with its sources. The profile decides the driver
|
||||
name, how the BMC is recognised, its usual system path, the boot mode rule, how
|
||||
to clear an override and the login method.
|
||||
|
||||
| Driver | Vendor | Boot mode in an override | Notes |
|
||||
|---|---|---|---|
|
||||
| `idrac` | Dell iDRAC 8/9/10 | not sent: a change starts a BIOS job | `System.Embedded.1`; iDRAC 10 takes boot settings at `.../Settings` |
|
||||
| `ilo` | HPE iLO 4 (2.30+), 5, 6 | current one, without it if refused | no PowerCycle (ForceRestart); no boot changes during POST (retried) |
|
||||
| `xcc` | Lenovo XCC, XCC2 | current one, without it if refused | override is Once only |
|
||||
| `supermicro` | Supermicro X11+, H12+ | always the current one | left out, some boards switch to Legacy |
|
||||
| `xfusion` | xFusion / Huawei iBMC | current one, without it if refused | session login; If-Match on every PATCH; ForcePowerCycle |
|
||||
| `kaytus` | KAYTUS / Inspur | current one, without it if refused | no public Redfish guide: reset may be on the chassis, a PATCH that worked can answer 400 |
|
||||
| `irmc` | Fujitsu / Fsas iRMC S5, S6 | not sent | `Systems/0`; ETag from the body; clearing is Target None (never Enabled Disabled); no GracefulShutdown (power button) |
|
||||
| `cimc` | Cisco UCS C-series CIMC | current one, without it if refused | `Systems/<serial>`, found through the collection |
|
||||
| `gigabyte` | GIGABYTE (MegaRAC SP-X) | always the current one | `Systems/Self`; If-Match required; short reset list |
|
||||
| `asrockrack` | ASRock Rack (MegaRAC) | always the current one | boot settings in `Systems/Self/SD` |
|
||||
| `openbmc` | OpenBMC (bmcweb) | current one, without it if refused | `Systems/system` |
|
||||
| `ami` | AMI MegaRAC SP-X boards | always the current one | `Systems/Self`; If-Match required |
|
||||
|
||||
Settings a machine can override:
|
||||
|
||||
| Setting | Effect |
|
||||
|---|---|
|
||||
| `system=` | the system path |
|
||||
| `boot_mode=UEFI\|Legacy` | always send this mode |
|
||||
| `auth=session\|basic` | login method |
|
||||
| `verify_tls=true` | check the BMC's certificate |
|
||||
|
||||
Every profile is tested against a fake service that behaves as its sources
|
||||
describe (`redfish_test.go`). The profiles have not yet run against the real
|
||||
machines.
|
||||
|
||||
After commissioning, a BMC still on IPMI is asked whether it speaks Redfish
|
||||
with the same account; if it does, the driver for its vendor takes over
|
||||
(event `bmc driver`). An operator's choice is never overridden.
|
||||
|
||||
+5
-290
@@ -34,17 +34,10 @@ type powerDriver struct {
|
||||
DefaultPort int `json:"default_port"`
|
||||
}
|
||||
|
||||
var powerDrivers = []powerDriver{
|
||||
{"ipmi", "IPMI 2.0 over LAN (ipmitool lanplus); any vendor", "ipmi", 623},
|
||||
{"redfish", "DMTF Redfish, any vendor", "redfish", 443},
|
||||
{"idrac", "Dell iDRAC 8/9 (Redfish)", "redfish", 443},
|
||||
{"ilo", "HPE iLO 4 (2.30+), 5, 6 (Redfish)", "redfish", 443},
|
||||
{"supermicro", "Supermicro X11 and later (Redfish)", "redfish", 443},
|
||||
{"xcc", "Lenovo XClarity Controller (Redfish)", "redfish", 443},
|
||||
{"openbmc", "OpenBMC (Redfish)", "redfish", 443},
|
||||
{"proxmox", "Proxmox VE virtual machine (API token: user = token id, password = secret; extra node, vmid)", "api", 8006},
|
||||
{"manual", "no power control: someone presses the button", "none", 0},
|
||||
}
|
||||
var powerDrivers = append(append([]powerDriver{{"ipmi", "IPMI 2.0 over LAN (ipmitool lanplus); any vendor", "ipmi", 623}},
|
||||
redfishDrivers()...),
|
||||
powerDriver{"proxmox", "Proxmox VE virtual machine (API token: user = token id, password = secret; extra node, vmid)", "api", 8006},
|
||||
powerDriver{"manual", "no power control: someone presses the button", "none", 0})
|
||||
|
||||
func driverByName(n string) (powerDriver, bool) {
|
||||
if n == "" {
|
||||
@@ -136,13 +129,7 @@ func ipmiExec(ctx context.Context, tool string, c *bmcCreds, args ...string) (st
|
||||
return strings.TrimSpace(out.String()), err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Redfish
|
||||
|
||||
type rfClient struct {
|
||||
base, user, pass string
|
||||
hc *http.Client
|
||||
etag string
|
||||
}
|
||||
// ---------------------------------------------------------------- BMC HTTP (Redfish, Proxmox)
|
||||
|
||||
func bmcHTTPClient(c *bmcCreds) *http.Client {
|
||||
// BMCs ship self-signed certificates; checking them is opt-in (extra verify_tls=true)
|
||||
@@ -175,278 +162,6 @@ func bmcBase(c *bmcCreds, scheme string, defPort int) string {
|
||||
return fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
}
|
||||
|
||||
func newRF(c *bmcCreds) *rfClient {
|
||||
return &rfClient{base: bmcBase(c, "https", 443), user: c.User, pass: c.Password, hc: bmcHTTPClient(c)}
|
||||
}
|
||||
|
||||
// resolve turns a path the BMC handed us (@odata.id, an action target, a task
|
||||
// monitor) into one on this BMC. Vendors return relative paths, full URLs and
|
||||
// trailing slashes; anything that would leave the BMC or the Redfish tree (and
|
||||
// take the credentials with it) is refused.
|
||||
func (r *rfClient) resolve(p string) (string, error) {
|
||||
if strings.Contains(p, "://") {
|
||||
u, err := url.Parse(p)
|
||||
b, _ := url.Parse(r.base)
|
||||
if err != nil || b == nil || !strings.EqualFold(u.Host, b.Host) || u.User != nil {
|
||||
return "", fmt.Errorf("the BMC pointed at %q, which is not this BMC", p)
|
||||
}
|
||||
p = u.RequestURI()
|
||||
}
|
||||
if !strings.HasPrefix(p, "/redfish/") || strings.Contains(p, "/../") || strings.HasSuffix(p, "/..") {
|
||||
return "", fmt.Errorf("the BMC pointed at %q, outside /redfish/", p)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *rfClient) do(ctx context.Context, method, path string, body, out any) (int, error) {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
path, err := r.resolve(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, r.base+path, rd)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if r.user != "" {
|
||||
req.SetBasicAuth(r.user, r.pass)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if method == "PATCH" && r.etag != "" {
|
||||
req.Header.Set("If-Match", r.etag) // some services (Supermicro, newer iDRAC) insist
|
||||
}
|
||||
resp, err := r.hc.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if method == "GET" {
|
||||
if et := resp.Header.Get("ETag"); et != "" {
|
||||
r.etag = et
|
||||
}
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return resp.StatusCode, fmt.Errorf("%s %s: %s %s", method, path, resp.Status, redfishMessage(data))
|
||||
}
|
||||
if out != nil && len(data) > 0 {
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return resp.StatusCode, fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// redfishMessage pulls the human message out of a Redfish error body.
|
||||
func redfishMessage(data []byte) string {
|
||||
var e struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Ext []struct {
|
||||
Message string `json:"Message"`
|
||||
} `json:"@Message.ExtendedInfo"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(data, &e) == nil {
|
||||
if len(e.Error.Ext) > 0 && e.Error.Ext[0].Message != "" {
|
||||
return e.Error.Ext[0].Message
|
||||
}
|
||||
if e.Error.Message != "" {
|
||||
return e.Error.Message
|
||||
}
|
||||
}
|
||||
return oneLine(string(data))
|
||||
}
|
||||
|
||||
// redfishSystemPath: extra system=…, else the vendor's usual one if it exists, else the first member.
|
||||
func (r *rfClient) system(ctx context.Context, c *bmcCreds) (string, obj, error) {
|
||||
var cands []string
|
||||
if s := c.Extra["system"]; s != "" {
|
||||
cands = append(cands, s)
|
||||
}
|
||||
switch c.Driver {
|
||||
case "idrac":
|
||||
cands = append(cands, "/redfish/v1/Systems/System.Embedded.1")
|
||||
case "ilo", "xcc", "supermicro":
|
||||
cands = append(cands, "/redfish/v1/Systems/1")
|
||||
}
|
||||
for _, p := range cands {
|
||||
var sys obj
|
||||
if _, err := r.do(ctx, "GET", p, nil, &sys); err == nil {
|
||||
return p, sys, nil
|
||||
} else if c.Extra["system"] != "" {
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
var coll struct {
|
||||
Members []struct {
|
||||
ID string `json:"@odata.id"`
|
||||
} `json:"Members"`
|
||||
}
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/Systems", nil, &coll); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(coll.Members) == 0 {
|
||||
return "", nil, errors.New("the BMC lists no systems")
|
||||
}
|
||||
p := coll.Members[0].ID
|
||||
var sys obj
|
||||
_, err := r.do(ctx, "GET", p, nil, &sys)
|
||||
return p, sys, err
|
||||
}
|
||||
|
||||
// resetTypes are the ResetType values the system accepts.
|
||||
func (r *rfClient) resetTypes(ctx context.Context, sys obj) []string {
|
||||
act, _ := dig(sys, "Actions", "#ComputerSystem.Reset").(obj)
|
||||
var out []string
|
||||
for _, v := range toList(act["ResetType@Redfish.AllowableValues"]) {
|
||||
out = append(out, str(v))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
if info := str(act["@Redfish.ActionInfo"]); info != "" {
|
||||
var ai struct {
|
||||
Parameters []struct {
|
||||
Name string `json:"Name"`
|
||||
AllowableValues []string `json:"AllowableValues"`
|
||||
} `json:"Parameters"`
|
||||
}
|
||||
if _, err := r.do(ctx, "GET", info, nil, &ai); err == nil {
|
||||
for _, p := range ai.Parameters {
|
||||
if p.Name == "ResetType" {
|
||||
out = p.AllowableValues
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pickReset(allowed []string, prefs ...string) string {
|
||||
if len(allowed) == 0 {
|
||||
return prefs[0]
|
||||
}
|
||||
for _, p := range prefs {
|
||||
if hasTag(allowed, p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var redfishBootTargets = map[string]string{"pxe": "Pxe", "disk": "Hdd", "bios": "BiosSetup", "cdrom": "Cd", "none": "None", "safe": "None"}
|
||||
|
||||
func redfishOp(ctx context.Context, c *bmcCreds, op, arg string) (string, error) {
|
||||
r := newRF(c)
|
||||
path, sys, err := r.system(ctx, c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
state := strings.ToLower(str(sys["PowerState"])) // On | Off | PoweringOn | PoweringOff
|
||||
on := state == "on" || state == "poweringon"
|
||||
reset := func(prefs ...string) (string, error) {
|
||||
t := pickReset(r.resetTypes(ctx, sys), prefs...)
|
||||
if t == "" {
|
||||
return "", fmt.Errorf("the BMC accepts none of %v", prefs)
|
||||
}
|
||||
target := str(dig(sys, "Actions", "#ComputerSystem.Reset", "target"))
|
||||
if target == "" {
|
||||
target = path + "/Actions/ComputerSystem.Reset"
|
||||
}
|
||||
if _, err := r.do(ctx, "POST", target, obj{"ResetType": t}, nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Chassis Power Control: " + t + " (redfish)", nil
|
||||
}
|
||||
switch op {
|
||||
case "status":
|
||||
if on {
|
||||
return "Chassis Power is on", nil
|
||||
}
|
||||
return "Chassis Power is off", nil
|
||||
case "on":
|
||||
if on {
|
||||
return "Chassis Power is on already", nil
|
||||
}
|
||||
return reset("On", "PushPowerButton")
|
||||
case "off":
|
||||
if !on {
|
||||
return "Chassis Power is off already", nil
|
||||
}
|
||||
return reset("ForceOff", "PushPowerButton")
|
||||
case "soft":
|
||||
if !on {
|
||||
return "Chassis Power is off already", nil
|
||||
}
|
||||
return reset("GracefulShutdown", "PushPowerButton")
|
||||
case "cycle":
|
||||
if !on {
|
||||
return reset("On", "PushPowerButton")
|
||||
}
|
||||
return reset("PowerCycle", "ForceRestart", "GracefulRestart")
|
||||
case "reset":
|
||||
if !on {
|
||||
return reset("On", "PushPowerButton")
|
||||
}
|
||||
return reset("ForceRestart", "PowerCycle", "GracefulRestart")
|
||||
case "bootdev":
|
||||
boot := obj{"BootSourceOverrideTarget": redfishBootTargets[arg], "BootSourceOverrideEnabled": "Once"}
|
||||
if arg == "none" || arg == "safe" {
|
||||
boot = obj{"BootSourceOverrideEnabled": "Disabled"}
|
||||
}
|
||||
if _, err := r.do(ctx, "PATCH", path, obj{"Boot": boot}, nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Set Boot Device to " + arg + " (redfish " + str(boot["BootSourceOverrideTarget"]) + ", once)", nil
|
||||
}
|
||||
return "", fmt.Errorf("redfish: %s is not supported", op)
|
||||
}
|
||||
|
||||
// redfishProbe asks whether the BMC speaks Redfish with these credentials and
|
||||
// suggests the driver for its vendor. The answer is JSON.
|
||||
func redfishProbe(ctx context.Context, c *bmcCreds) (string, error) {
|
||||
r := newRF(c)
|
||||
var root obj
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/", nil, &root); err != nil {
|
||||
return "", fmt.Errorf("no Redfish service: %w", err)
|
||||
}
|
||||
res := obj{"redfish": str(root["RedfishVersion"]), "vendor": str(root["Vendor"])}
|
||||
if res["vendor"] == "" {
|
||||
for k := range asObj(root["Oem"]) {
|
||||
res["vendor"] = k
|
||||
}
|
||||
}
|
||||
path, sys, err := r.system(ctx, &bmcCreds{Driver: "redfish", Extra: c.Extra, User: c.User, Password: c.Password, Address: c.Address, Port: c.Port})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Redfish %s answers, but not to these credentials: %w", res["redfish"], err)
|
||||
}
|
||||
res["system"], res["manufacturer"], res["model"], res["power"] = path, str(sys["Manufacturer"]), str(sys["Model"]), str(sys["PowerState"])
|
||||
v := strings.ToLower(str(res["vendor"]) + " " + str(res["manufacturer"]))
|
||||
switch {
|
||||
case strings.Contains(v, "dell"):
|
||||
res["driver"] = "idrac"
|
||||
case strings.Contains(v, "hpe"), strings.Contains(v, "hewlett"), strings.Contains(v, "hp "):
|
||||
res["driver"] = "ilo"
|
||||
case strings.Contains(v, "supermicro"):
|
||||
res["driver"] = "supermicro"
|
||||
case strings.Contains(v, "lenovo"):
|
||||
res["driver"] = "xcc"
|
||||
case strings.Contains(v, "openbmc"):
|
||||
res["driver"] = "openbmc"
|
||||
default:
|
||||
res["driver"] = "redfish"
|
||||
}
|
||||
b, _ := json.Marshal(res)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func asObj(v any) obj {
|
||||
o, _ := v.(obj)
|
||||
return o
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
package main
|
||||
|
||||
// Redfish power driver: one implementation of the DMTF standard, and what each
|
||||
// vendor documents differently in redfishProfiles (with the sources).
|
||||
//
|
||||
// For every vendor:
|
||||
// - ResetType values and boot targets come from @Redfish.AllowableValues
|
||||
// (or the action's ActionInfo); nothing is assumed.
|
||||
// - A one-time boot override sends target and enable in one PATCH, the boot
|
||||
// mode as the profile says (keep / omit / try), and only writable fields.
|
||||
// - PATCH carries If-Match with the resource's ETag (header or
|
||||
// @odata.etag). On 412 the strong form and then none are tried; on 428
|
||||
// the ETag is fetched first.
|
||||
// - A service that keeps pending settings elsewhere (AMI's .../SD,
|
||||
// @Redfish.Settings) gets the same PATCH there.
|
||||
// - "Busy" answers are retried for up to a minute: the server is in POST,
|
||||
// 409 right after a power change, 503, "Retry in N seconds".
|
||||
// - 202 answers are followed to their task monitor.
|
||||
// - A power cycle the system does not offer is ForceOff, wait for Off, On.
|
||||
//
|
||||
// Field notes behind these rules: OpenStack Ironic/sushy, MAAS and bmclib.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type redfishProfile struct {
|
||||
Driver string // the power driver name
|
||||
Title string // for people
|
||||
Vendors []string // lower-case substrings of ServiceRoot Vendor or the system's Manufacturer
|
||||
OemKeys []string // ServiceRoot Oem keys that identify it
|
||||
Systems []string // its usual ComputerSystem, tried before the collection
|
||||
// BootMode: BootSourceOverrideMode in a boot override.
|
||||
// keep: always send the current mode (left out, the BMC may switch UEFI to Legacy)
|
||||
// omit: never send it unless the operator sets boot_mode (a change starts a BIOS job)
|
||||
// try: send the current mode; again without it if the BMC refuses the body
|
||||
BootMode string
|
||||
// Clear: bodies that clear a boot override, tried in order (default: Enabled Disabled)
|
||||
Clear []obj
|
||||
// Auth: "session" logs in through the SessionService (X-Auth-Token); default basic.
|
||||
Auth string
|
||||
Sources []string
|
||||
}
|
||||
|
||||
// redfishProfiles, most specific first: the AMI-based ones (Gigabyte, ASRock
|
||||
// Rack) also carry Oem.Ami, so they come before the generic AMI entry.
|
||||
var redfishProfiles = []redfishProfile{
|
||||
{Driver: "idrac", Title: "Dell iDRAC 8/9/10", Vendors: []string{"dell"}, OemKeys: []string{"Dell"},
|
||||
Systems: []string{"/redfish/v1/Systems/System.Embedded.1"}, BootMode: "omit",
|
||||
Sources: []string{
|
||||
"https://dl.dell.com/topicspdf/idrac9-lifecycle-controller-v33-series_api-guide2_en-us.pdf",
|
||||
"https://github.com/dell/iDRAC-Redfish-Scripting/blob/master/Redfish%20Python/SetNextOneTimeBootDeviceREDFISH.py",
|
||||
}},
|
||||
{Driver: "ilo", Title: "HPE iLO 4 (2.30+), 5, 6", Vendors: []string{"hpe", "hewlett"}, OemKeys: []string{"Hpe", "Hp"},
|
||||
Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "try",
|
||||
Sources: []string{
|
||||
"https://servermanagementportal.ext.hpe.com/docs/redfishservices/ilos/ilo5/ilo5_307/ilo5_computersystem_resourcedefns307",
|
||||
"https://servermanagementportal.ext.hpe.com/docs/references_and_material/blogposts/etc/masterserverstates/masterserverstates.md",
|
||||
"https://servermanagementportal.ext.hpe.com/docs/concepts/errorresponses",
|
||||
}},
|
||||
{Driver: "xcc", Title: "Lenovo XClarity Controller (XCC, XCC2)", Vendors: []string{"lenovo"},
|
||||
Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "try",
|
||||
Sources: []string{
|
||||
"https://pubs.lenovo.com/xcc-restapi/update_next_onetime_bootconfig_patch",
|
||||
"https://pubs.lenovo.com/xcc-restapi/server_reset_operations_post",
|
||||
}},
|
||||
{Driver: "supermicro", Title: "Supermicro X11 and later, H12 and later", Vendors: []string{"supermicro"}, OemKeys: []string{"Supermicro"},
|
||||
Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "keep",
|
||||
Sources: []string{
|
||||
"https://www.supermicro.com/manuals/other/RedfishUserGuide.pdf",
|
||||
"https://storyboard.openstack.org/#!/story/2008252",
|
||||
}},
|
||||
{Driver: "xfusion", Title: "xFusion / Huawei FusionServer iBMC", Vendors: []string{"xfusion", "huawei"}, OemKeys: []string{"xFusion", "Huawei"},
|
||||
Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "try", Auth: "session",
|
||||
Sources: []string{
|
||||
"https://www.xfusion.com/wp-content/uploads/2025/08/FusionServer-iBMC-User-Guide.pdf",
|
||||
"https://github.com/Open-xFusion/Server_Plugin_Ansible/blob/HEAD/ibmc_ansible/ibmc_redfish_api/api_manage_boot_device.py",
|
||||
"https://github.com/Huawei/Huawei-iBMC-Cmdlets/blob/HEAD/code/common/Types.ps1",
|
||||
}},
|
||||
{Driver: "kaytus", Title: "KAYTUS / Inspur (no public Redfish guide: check on hardware)", Vendors: []string{"kaytus", "inspur"}, OemKeys: []string{"Public"},
|
||||
Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "try",
|
||||
Sources: []string{
|
||||
"https://github.com/ilkermanap/pyredfish/blob/HEAD/pyredfish/vendors/kaytus.py",
|
||||
"https://github.com/erelbi/kaytus-bmc-sdk",
|
||||
"https://www.commoncriteriaportal.org/files/epfiles/Kaytus%20ST%20v27a.pdf",
|
||||
}},
|
||||
{Driver: "irmc", Title: "Fujitsu / Fsas iRMC S5, S6", Vendors: []string{"fujitsu", "ts_fujitsu", "fsas"}, OemKeys: []string{"ts_fujitsu"},
|
||||
Systems: []string{"/redfish/v1/Systems/0"}, BootMode: "omit",
|
||||
// Enabled "Disabled" is refused on every firmware; 1.20 wants Continuous with None
|
||||
Clear: []obj{{"BootSourceOverrideTarget": "None"}, {"BootSourceOverrideTarget": "None", "BootSourceOverrideEnabled": "Continuous"}},
|
||||
Sources: []string{
|
||||
"https://github.com/fujitsu/ansible-irmc-integration/blob/HEAD/plugins/modules/irmc_setnextboot.py",
|
||||
"https://github.com/fujitsu/ansible-irmc-integration/blob/HEAD/plugins/modules/irmc_powerstate.py",
|
||||
}},
|
||||
{Driver: "cimc", Title: "Cisco UCS C-series CIMC (standalone)", Vendors: []string{"cisco"}, OemKeys: []string{"Cisco"},
|
||||
BootMode: "try", // the system is /Systems/<serial>: found through the collection
|
||||
Sources: []string{"https://www.cisco.com/c/en/us/td/docs/unified_computing/ucs/c/sw/api/4_3/b-cisco-ucs-c-series-servers-rest-api-programmer-s-guide-release-4-3.pdf"}},
|
||||
{Driver: "gigabyte", Title: "GIGABYTE servers (MegaRAC SP-X BMC)", Vendors: []string{"giga-byte", "gigabyte"},
|
||||
Systems: []string{"/redfish/v1/Systems/Self"}, BootMode: "keep",
|
||||
Sources: []string{"https://download.gigabyte.com/FileList/Manual/server_manual_redfish_v1.11.0.pdf"}},
|
||||
{Driver: "asrockrack", Title: "ASRock Rack (MegaRAC BMC)", Vendors: []string{"asrockrack", "asrock rack", "asrock"},
|
||||
Systems: []string{"/redfish/v1/Systems/Self"}, BootMode: "keep",
|
||||
Sources: []string{"https://bugs.launchpad.net/ironic/+bug/2073518", "https://opendev.org/openstack/ironic/commit/54977a14"}},
|
||||
{Driver: "openbmc", Title: "OpenBMC (bmcweb)", Vendors: []string{"openbmc"},
|
||||
Systems: []string{"/redfish/v1/Systems/system"}, BootMode: "try",
|
||||
Sources: []string{"https://github.com/openbmc/bmcweb/blob/master/redfish-core/lib/systems.hpp"}},
|
||||
{Driver: "ami", Title: "AMI MegaRAC SP-X (many ODM boards)", Vendors: []string{"american megatrends"}, OemKeys: []string{"Ami"},
|
||||
Systems: []string{"/redfish/v1/Systems/Self"}, BootMode: "keep",
|
||||
Sources: []string{"https://download-2.msi.com/archive/mnu_exe/server/MSI-Redfish-API-v1.0-UG-250801.pdf"}},
|
||||
{Driver: "redfish", Title: "DMTF Redfish, any vendor", BootMode: "try",
|
||||
Sources: []string{"https://www.dmtf.org/standards/redfish"}},
|
||||
}
|
||||
|
||||
func redfishProfileFor(driver string) redfishProfile {
|
||||
for _, p := range redfishProfiles {
|
||||
if p.Driver == driver {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return redfishProfiles[len(redfishProfiles)-1]
|
||||
}
|
||||
|
||||
// matchRedfishProfile picks the profile for what a service says about itself.
|
||||
func matchRedfishProfile(vendor, manufacturer string, oemKeys []string) redfishProfile {
|
||||
v := strings.ToLower(vendor + " " + manufacturer)
|
||||
for _, p := range redfishProfiles {
|
||||
for _, s := range p.Vendors {
|
||||
if strings.Contains(v, s) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
for _, k := range p.OemKeys {
|
||||
if hasTag(oemKeys, k) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
return redfishProfiles[len(redfishProfiles)-1]
|
||||
}
|
||||
|
||||
func redfishDrivers() []powerDriver {
|
||||
var out []powerDriver
|
||||
for _, p := range redfishProfiles {
|
||||
if p.Driver == "redfish" { // the generic one first
|
||||
out = append([]powerDriver{{p.Driver, p.Title, "redfish", 443}}, out...)
|
||||
continue
|
||||
}
|
||||
out = append(out, powerDriver{p.Driver, p.Title + " (Redfish)", "redfish", 443})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- client
|
||||
|
||||
type rfClient struct {
|
||||
base, user, pass string
|
||||
hc *http.Client
|
||||
etags map[string]string // resource -> ETag from its last GET
|
||||
token, session string // X-Auth-Token and the session to delete, when logged in
|
||||
}
|
||||
|
||||
// login opens a session (profile or extra auth=session); logout closes it.
|
||||
func (r *rfClient) login(ctx context.Context) error {
|
||||
resp, err := r.call(ctx, "POST", "/redfish/v1/SessionService/Sessions", obj{"UserName": r.user, "Password": r.pass}, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("session login: %w", err)
|
||||
}
|
||||
if r.token = resp.hdr.Get("X-Auth-Token"); r.token == "" {
|
||||
return errors.New("session login: no X-Auth-Token in the answer")
|
||||
}
|
||||
r.session = resp.hdr.Get("Location")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rfClient) logout() {
|
||||
if r.session == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_, _ = r.call(ctx, "DELETE", r.session, nil, "") // sessions are few: never leave one behind
|
||||
r.token, r.session = "", ""
|
||||
}
|
||||
|
||||
// open logs in when the profile or the operator wants sessions.
|
||||
func (r *rfClient) open(ctx context.Context, c *bmcCreds, prof redfishProfile) error {
|
||||
if a := c.Extra["auth"]; a == "session" || (a == "" && prof.Auth == "session") {
|
||||
return r.login(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rfSleep waits between retries; tests make it instant.
|
||||
var rfSleep = func(ctx context.Context, d time.Duration) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(d):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newRF(c *bmcCreds) *rfClient {
|
||||
return &rfClient{base: bmcBase(c, "https", 443), user: c.User, pass: c.Password, hc: bmcHTTPClient(c), etags: map[string]string{}}
|
||||
}
|
||||
|
||||
// resolve turns a path the BMC handed us (@odata.id, an action target, a task
|
||||
// monitor) into one on this BMC. Vendors return relative paths, full URLs and
|
||||
// trailing slashes; anything that would leave the BMC or the Redfish tree (and
|
||||
// take the credentials with it) is refused.
|
||||
func (r *rfClient) resolve(p string) (string, error) {
|
||||
if strings.Contains(p, "://") {
|
||||
u, err := url.Parse(p)
|
||||
b, _ := url.Parse(r.base)
|
||||
if err != nil || b == nil || !strings.EqualFold(u.Host, b.Host) || u.User != nil {
|
||||
return "", fmt.Errorf("the BMC pointed at %q, which is not this BMC", p)
|
||||
}
|
||||
p = u.RequestURI()
|
||||
}
|
||||
if !strings.HasPrefix(p, "/redfish/") || strings.Contains(p, "/../") || strings.HasSuffix(p, "/..") {
|
||||
return "", fmt.Errorf("the BMC pointed at %q, outside /redfish/", p)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
type rfResp struct {
|
||||
code int
|
||||
hdr http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
// rfError is a refused request, with the registry message IDs it carried.
|
||||
type rfError struct {
|
||||
method, path string
|
||||
code int
|
||||
ids []string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *rfError) Error() string {
|
||||
return fmt.Sprintf("%s %s: %d %s", e.method, e.path, e.code, e.msg)
|
||||
}
|
||||
|
||||
// has: one of the message IDs ends in key ("Ami.1.0.OperationSupportedInFutureStateURI").
|
||||
func (e *rfError) has(key string) bool {
|
||||
for _, id := range e.ids {
|
||||
if strings.HasSuffix(id, "."+key) || id == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.Contains(e.msg, key)
|
||||
}
|
||||
|
||||
// succeeded: the error body says the request worked (only Success messages).
|
||||
func (e *rfError) succeeded() bool {
|
||||
if len(e.ids) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, id := range e.ids {
|
||||
if !strings.HasSuffix(id, ".Success") && !strings.HasSuffix(id, "GeneralError") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return e.has("Success") || strings.Contains(strings.ToLower(e.msg), "completed successfully")
|
||||
}
|
||||
|
||||
var retryInRe = regexp.MustCompile(`(?i)retry in (\d+) seconds?`)
|
||||
|
||||
// busy: the service will take the same request a little later.
|
||||
func (e *rfError) busy() (time.Duration, bool) {
|
||||
if m := retryInRe.FindStringSubmatch(e.msg); m != nil {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return time.Duration(min(max(n, 1), 30)) * time.Second, true
|
||||
}
|
||||
switch {
|
||||
case e.has("UnableToModifyDuringSystemPOST"): // HPE: boot settings only when off or past POST
|
||||
return 10 * time.Second, true
|
||||
case e.code == http.StatusConflict, e.code == http.StatusServiceUnavailable: // right after a power change
|
||||
return 5 * time.Second, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func asRFError(err error) *rfError {
|
||||
var e *rfError
|
||||
if errors.As(err, &e) {
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// call sends one request; ifMatch "" sends none.
|
||||
func (r *rfClient) call(ctx context.Context, method, path string, body any, ifMatch string) (*rfResp, error) {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
path, err := r.resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, r.base+path, rd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.token != "" {
|
||||
req.Header.Set("X-Auth-Token", r.token)
|
||||
} else if r.user != "" && !(method == "POST" && strings.HasSuffix(path, "/SessionService/Sessions")) {
|
||||
req.SetBasicAuth(r.user, r.pass)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if ifMatch != "" {
|
||||
req.Header.Set("If-Match", ifMatch)
|
||||
}
|
||||
resp, err := r.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
out := &rfResp{code: resp.StatusCode, hdr: resp.Header, body: data}
|
||||
if resp.StatusCode >= 300 {
|
||||
ids, msg := redfishMessage(data)
|
||||
e := &rfError{method: method, path: path, code: resp.StatusCode, ids: ids, msg: resp.Status + " " + msg}
|
||||
if resp.StatusCode == http.StatusBadRequest && method != "GET" && e.succeeded() { // KAYTUS: 400 with "completed successfully"
|
||||
return out, nil
|
||||
}
|
||||
return out, e
|
||||
}
|
||||
if method == "GET" {
|
||||
et := resp.Header.Get("ETag")
|
||||
if et == "" {
|
||||
var o struct {
|
||||
ETag json.RawMessage `json:"@odata.etag"` // iRMC: a bare number
|
||||
}
|
||||
_ = json.Unmarshal(data, &o)
|
||||
et = strings.Trim(string(o.ETag), `"`)
|
||||
if len(o.ETag) > 0 && o.ETag[0] == '"' {
|
||||
var s string
|
||||
_ = json.Unmarshal(o.ETag, &s)
|
||||
et = s
|
||||
}
|
||||
}
|
||||
if et != "" {
|
||||
r.etags[path] = et
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// do is call plus decoding; GETs record the resource's ETag.
|
||||
func (r *rfClient) do(ctx context.Context, method, path string, body, out any) (int, error) {
|
||||
resp, err := r.call(ctx, method, path, body, "")
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
return resp.code, err
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if out != nil && len(resp.body) > 0 {
|
||||
if err := json.Unmarshal(resp.body, out); err != nil {
|
||||
return resp.code, fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
}
|
||||
return resp.code, nil
|
||||
}
|
||||
|
||||
// redfishMessage pulls the message IDs and the human message out of an error body.
|
||||
func redfishMessage(data []byte) ([]string, string) {
|
||||
var e struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Ext []struct {
|
||||
ID string `json:"MessageId"`
|
||||
Message string `json:"Message"`
|
||||
} `json:"@Message.ExtendedInfo"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(data, &e) != nil {
|
||||
return nil, oneLine(string(data)) // some BMCs' web servers answer errors in HTML
|
||||
}
|
||||
var ids, msgs []string
|
||||
for _, x := range e.Error.Ext {
|
||||
if x.ID != "" {
|
||||
ids = append(ids, x.ID)
|
||||
}
|
||||
if x.Message != "" {
|
||||
msgs = append(msgs, x.Message)
|
||||
} else if x.ID != "" { // HPE often sends only the ID
|
||||
msgs = append(msgs, x.ID)
|
||||
}
|
||||
}
|
||||
if e.Error.Code != "" {
|
||||
ids = append(ids, e.Error.Code)
|
||||
}
|
||||
if len(msgs) == 0 && e.Error.Message != "" {
|
||||
msgs = append(msgs, e.Error.Message)
|
||||
}
|
||||
return ids, strings.Join(msgs, "; ")
|
||||
}
|
||||
|
||||
// patch sends a PATCH the way picky services want it (If-Match, 412, 428).
|
||||
func (r *rfClient) patch(ctx context.Context, path string, body obj) (*rfResp, error) {
|
||||
et := r.etags[path]
|
||||
resp, err := r.call(ctx, "PATCH", path, body, et)
|
||||
e := asRFError(err)
|
||||
switch {
|
||||
case e == nil:
|
||||
case e.code == http.StatusPreconditionRequired: // 428: wants If-Match (AMI)
|
||||
if _, gerr := r.call(ctx, "GET", path, nil, ""); gerr == nil && r.etags[path] != "" {
|
||||
resp, err = r.call(ctx, "PATCH", path, body, r.etags[path])
|
||||
}
|
||||
case e.code == http.StatusPreconditionFailed && et != "": // 412: weak ETag refused, or stale
|
||||
if strong := strings.TrimPrefix(et, "W/"); strong != et {
|
||||
resp, err = r.call(ctx, "PATCH", path, body, strong)
|
||||
}
|
||||
if e2 := asRFError(err); e2 != nil && e2.code == http.StatusPreconditionFailed {
|
||||
resp, err = r.call(ctx, "PATCH", path, body, "")
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
delete(r.etags, path) // changed: never reuse it
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// retry runs f again while the service says it is busy, for up to a minute.
|
||||
func retry(ctx context.Context, f func() error) error {
|
||||
deadline := time.Now().Add(time.Minute)
|
||||
for {
|
||||
err := f()
|
||||
e := asRFError(err)
|
||||
if e == nil {
|
||||
return err
|
||||
}
|
||||
d, ok := e.busy()
|
||||
if !ok || time.Now().Add(d).After(deadline) {
|
||||
return err
|
||||
}
|
||||
if serr := rfSleep(ctx, d); serr != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// follow waits for a 202's task (or Dell job) to finish.
|
||||
func (r *rfClient) follow(ctx context.Context, resp *rfResp) error {
|
||||
if resp == nil || resp.code != http.StatusAccepted {
|
||||
return nil
|
||||
}
|
||||
loc := resp.hdr.Get("Location")
|
||||
if loc == "" {
|
||||
var t struct {
|
||||
ID string `json:"@odata.id"`
|
||||
}
|
||||
_ = json.Unmarshal(resp.body, &t)
|
||||
loc = t.ID
|
||||
}
|
||||
if loc == "" {
|
||||
return nil
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Minute)
|
||||
for time.Now().Before(deadline) {
|
||||
res, err := r.call(ctx, "GET", loc, nil, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var t obj
|
||||
_ = json.Unmarshal(res.body, &t)
|
||||
st := str(t["TaskState"]) + str(t["JobState"])
|
||||
switch st {
|
||||
case "Completed":
|
||||
return nil
|
||||
case "Exception", "Killed", "Cancelled", "Failed", "CompletedWithErrors":
|
||||
msg := ""
|
||||
if ms := toList(t["Messages"]); len(ms) > 0 {
|
||||
msg = str(asObj(ms[0])["Message"])
|
||||
}
|
||||
return fmt.Errorf("task %s: %s %s", loc, st, msg)
|
||||
}
|
||||
if res.code == http.StatusOK && st == "" { // the monitor answered with the result itself
|
||||
return nil
|
||||
}
|
||||
if err := rfSleep(ctx, 3*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("task %s still running after 2 minutes", loc)
|
||||
}
|
||||
|
||||
// system finds the ComputerSystem: extra system=..., else the vendor's usual
|
||||
// one if it exists, else the first member of the collection.
|
||||
func (r *rfClient) system(ctx context.Context, c *bmcCreds, prof redfishProfile) (string, obj, error) {
|
||||
cands := append([]string(nil), prof.Systems...)
|
||||
if s := c.Extra["system"]; s != "" {
|
||||
cands = []string{s}
|
||||
}
|
||||
for _, p := range cands {
|
||||
var sys obj
|
||||
if _, err := r.do(ctx, "GET", p, nil, &sys); err == nil {
|
||||
return p, sys, nil
|
||||
} else if c.Extra["system"] != "" {
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
var coll struct {
|
||||
Members []struct {
|
||||
ID string `json:"@odata.id"`
|
||||
} `json:"Members"`
|
||||
}
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/Systems", nil, &coll); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(coll.Members) == 0 {
|
||||
return "", nil, errors.New("the BMC lists no systems")
|
||||
}
|
||||
p := strings.TrimRight(coll.Members[0].ID, "/") // iLO lists members with a trailing slash
|
||||
var sys obj
|
||||
_, err := r.do(ctx, "GET", p, nil, &sys)
|
||||
return p, sys, err
|
||||
}
|
||||
|
||||
// resetTypes are the ResetType values the system accepts.
|
||||
func (r *rfClient) resetTypes(ctx context.Context, sys obj) []string {
|
||||
act := asObj(dig(sys, "Actions", "#ComputerSystem.Reset"))
|
||||
var out []string
|
||||
for _, v := range toList(act["ResetType@Redfish.AllowableValues"]) {
|
||||
out = append(out, str(v))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
if info := str(act["@Redfish.ActionInfo"]); info != "" {
|
||||
var ai struct {
|
||||
Parameters []struct {
|
||||
Name string `json:"Name"`
|
||||
AllowableValues []string `json:"AllowableValues"`
|
||||
} `json:"Parameters"`
|
||||
}
|
||||
if _, err := r.do(ctx, "GET", info, nil, &ai); err == nil {
|
||||
for _, p := range ai.Parameters {
|
||||
if p.Name == "ResetType" {
|
||||
out = p.AllowableValues
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pickReset(allowed []string, prefs ...string) string {
|
||||
if len(allowed) == 0 {
|
||||
return prefs[0]
|
||||
}
|
||||
for _, p := range prefs {
|
||||
if hasTag(allowed, p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func strList(v any) []string {
|
||||
var out []string
|
||||
for _, x := range toList(v) {
|
||||
out = append(out, str(x))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var redfishBootTargets = map[string]string{"pxe": "Pxe", "disk": "Hdd", "bios": "BiosSetup", "cdrom": "Cd", "none": "", "safe": ""}
|
||||
|
||||
// bootOverride sets the next boot (target "" clears the override).
|
||||
func (r *rfClient) bootOverride(ctx context.Context, c *bmcCreds, prof redfishProfile, path string, sys obj, target string) error {
|
||||
boot := asObj(sys["Boot"])
|
||||
if allowed := strList(boot["BootSourceOverrideTarget@Redfish.AllowableValues"]); target != "" && len(allowed) > 0 && !hasTag(allowed, target) {
|
||||
return fmt.Errorf("the BMC offers boot targets %s, not %s", strings.Join(allowed, ", "), target)
|
||||
}
|
||||
if target == "" {
|
||||
clears := prof.Clear
|
||||
if len(clears) == 0 {
|
||||
clears = []obj{{"BootSourceOverrideEnabled": "Disabled"}}
|
||||
}
|
||||
var err error
|
||||
for _, b := range clears {
|
||||
if err = retry(ctx, func() error { _, err := r.patch(ctx, path, obj{"Boot": b}); return err }); err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, gerr := r.call(ctx, "GET", path, nil, ""); gerr != nil { // fresh ETag for the next try
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
body := obj{"BootSourceOverrideTarget": target, "BootSourceOverrideEnabled": "Once"}
|
||||
mode, asked := str(boot["BootSourceOverrideMode"]), c.Extra["boot_mode"] // UEFI | Legacy
|
||||
if asked != "" {
|
||||
mode = asked
|
||||
}
|
||||
withMode := mode != "" && (prof.BootMode != "omit" || asked != "")
|
||||
full := obj{}
|
||||
for k, v := range body {
|
||||
full[k] = v
|
||||
}
|
||||
if withMode {
|
||||
full["BootSourceOverrideMode"] = mode
|
||||
}
|
||||
send := func(p string, b obj) error {
|
||||
return retry(ctx, func() error {
|
||||
resp, err := r.patch(ctx, p, obj{"Boot": b})
|
||||
if err == nil {
|
||||
err = r.follow(ctx, resp)
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
err := send(path, full)
|
||||
e := asRFError(err)
|
||||
if e != nil && e.has("OperationSupportedInFutureStateURI") { // AMI: pending settings live in .../SD
|
||||
sd := path + "/SD"
|
||||
if _, gerr := r.call(ctx, "GET", sd, nil, ""); gerr == nil {
|
||||
err = send(sd, full)
|
||||
}
|
||||
e = asRFError(err)
|
||||
}
|
||||
if settings := str(dig(sys, "@Redfish.Settings", "SettingsObject", "@odata.id")); e != nil && settings != "" &&
|
||||
(e.code == http.StatusBadRequest || e.code == http.StatusMethodNotAllowed) { // e.g. iDRAC 10: .../Settings
|
||||
if _, gerr := r.call(ctx, "GET", settings, nil, ""); gerr == nil {
|
||||
err = send(settings, full)
|
||||
}
|
||||
e = asRFError(err)
|
||||
}
|
||||
if e != nil && withMode && prof.BootMode == "try" && asked == "" && e.code == http.StatusBadRequest {
|
||||
err = send(path, body) // some refuse the mode in the body (HPE DL160 Gen10)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func redfishOp(ctx context.Context, c *bmcCreds, op, arg string) (string, error) {
|
||||
prof := redfishProfileFor(c.Driver)
|
||||
r := newRF(c)
|
||||
if err := r.open(ctx, c, prof); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer r.logout()
|
||||
path, sys, err := r.system(ctx, c, prof)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
powerState := func(sys obj) string { return strings.ToLower(str(sys["PowerState"])) } // On | Off | PoweringOn | PoweringOff | Reset (iLO, briefly)
|
||||
on := powerState(sys) == "on" || powerState(sys) == "poweringon"
|
||||
allowed := r.resetTypes(ctx, sys)
|
||||
target := str(dig(sys, "Actions", "#ComputerSystem.Reset", "target"))
|
||||
if target == "" && dig(sys, "Actions", "#ComputerSystem.Reset") == nil {
|
||||
// no reset on the system (seen on KAYTUS): the chassis' one, if it has
|
||||
if ch := toList(dig(sys, "Links", "Chassis")); len(ch) > 0 {
|
||||
var chassis obj
|
||||
if _, err := r.do(ctx, "GET", str(asObj(ch[0])["@odata.id"]), nil, &chassis); err == nil {
|
||||
if t := str(dig(chassis, "Actions", "#Chassis.Reset", "target")); t != "" {
|
||||
target = t
|
||||
allowed = strList(dig(chassis, "Actions", "#Chassis.Reset", "ResetType@Redfish.AllowableValues"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if target == "" {
|
||||
target = path + "/Actions/ComputerSystem.Reset"
|
||||
}
|
||||
post := func(t string) error {
|
||||
return retry(ctx, func() error {
|
||||
resp, err := r.call(ctx, "POST", target, obj{"ResetType": t}, "")
|
||||
if err == nil {
|
||||
err = r.follow(ctx, resp)
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
reset := func(prefs ...string) (string, error) {
|
||||
t := pickReset(allowed, prefs...)
|
||||
if t == "" {
|
||||
return "", fmt.Errorf("the BMC accepts none of %v (it offers %v)", prefs, allowed)
|
||||
}
|
||||
if err := post(t); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Chassis Power Control: " + t + " (redfish)", nil
|
||||
}
|
||||
// waitOff: ForceOff is not instant; On before the system is off is refused
|
||||
waitOff := func() error {
|
||||
for i := 0; i < 30; i++ {
|
||||
var s obj
|
||||
if _, err := r.do(ctx, "GET", path, nil, &s); err == nil && powerState(s) == "off" {
|
||||
return nil
|
||||
}
|
||||
if err := rfSleep(ctx, 2*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return errors.New("the system did not power off within a minute")
|
||||
}
|
||||
switch op {
|
||||
case "status":
|
||||
if on {
|
||||
return "Chassis Power is on", nil
|
||||
}
|
||||
return "Chassis Power is off", nil
|
||||
case "on":
|
||||
if on {
|
||||
return "Chassis Power is on already", nil
|
||||
}
|
||||
return reset("On", "ForceOn", "PushPowerButton")
|
||||
case "off":
|
||||
if !on {
|
||||
return "Chassis Power is off already", nil
|
||||
}
|
||||
return reset("ForceOff", "PushPowerButton")
|
||||
case "soft":
|
||||
if !on {
|
||||
return "Chassis Power is off already", nil
|
||||
}
|
||||
return reset("GracefulShutdown", "PushPowerButton")
|
||||
case "cycle", "reset":
|
||||
if !on {
|
||||
return reset("On", "ForceOn", "PushPowerButton")
|
||||
}
|
||||
prefs := []string{"PowerCycle", "ForcePowerCycle", "ForceRestart", "GracefulRestart"} // ForcePowerCycle: iBMC
|
||||
if op == "reset" {
|
||||
prefs = []string{"ForceRestart", "PowerCycle", "ForcePowerCycle", "GracefulRestart"}
|
||||
}
|
||||
if pickReset(allowed, prefs...) == "" && hasTag(allowed, "ForceOff") && hasTag(allowed, "On") {
|
||||
if err := post("ForceOff"); err != nil { // offered no restart at all (some AMI builds)
|
||||
return "", err
|
||||
}
|
||||
if err := waitOff(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := post("On"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Chassis Power Control: ForceOff, On (redfish)", nil
|
||||
}
|
||||
return reset(prefs...)
|
||||
case "bootdev":
|
||||
t := redfishBootTargets[arg]
|
||||
if err := r.bootOverride(ctx, c, prof, path, sys, t); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if t == "" {
|
||||
return "Boot override cleared (redfish)", nil
|
||||
}
|
||||
return "Set Boot Device to " + arg + " (redfish " + t + ", once)", nil
|
||||
}
|
||||
return "", fmt.Errorf("redfish: %s is not supported", op)
|
||||
}
|
||||
|
||||
// redfishProbe asks whether the BMC speaks Redfish with these credentials,
|
||||
// which vendor profile fits, and what the system offers. The answer is JSON
|
||||
// and is kept on the BMC record (shown in the UI).
|
||||
func redfishProbe(ctx context.Context, c *bmcCreds) (string, error) {
|
||||
r := newRF(c)
|
||||
var root obj
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/", nil, &root); err != nil { // the service root needs no login
|
||||
return "", fmt.Errorf("no Redfish service: %w", err)
|
||||
}
|
||||
var oemKeys []string
|
||||
for k := range asObj(root["Oem"]) {
|
||||
oemKeys = append(oemKeys, k)
|
||||
}
|
||||
vendor := str(root["Vendor"])
|
||||
if vendor == "" && len(oemKeys) > 0 {
|
||||
vendor = oemKeys[0]
|
||||
}
|
||||
res := obj{"redfish": str(root["RedfishVersion"]), "vendor": vendor}
|
||||
// the system is looked up with the profile the root suggests; the
|
||||
// manufacturer then settles it
|
||||
guess := matchRedfishProfile(vendor, "", oemKeys)
|
||||
if err := r.open(ctx, c, guess); err != nil {
|
||||
return "", fmt.Errorf("Redfish %s answers, but not to these credentials: %w", res["redfish"], err)
|
||||
}
|
||||
defer r.logout()
|
||||
path, sys, err := r.system(ctx, &bmcCreds{Extra: c.Extra}, guess)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Redfish %s answers, but not to these credentials: %w", res["redfish"], err)
|
||||
}
|
||||
prof := matchRedfishProfile(vendor, str(sys["Manufacturer"]), oemKeys)
|
||||
boot := asObj(sys["Boot"])
|
||||
res["system"], res["manufacturer"], res["model"], res["power"] = path, str(sys["Manufacturer"]), str(sys["Model"]), str(sys["PowerState"])
|
||||
res["driver"], res["profile"] = prof.Driver, prof.Title
|
||||
if prof.Auth == "session" {
|
||||
res["auth"] = "session"
|
||||
}
|
||||
res["reset_types"] = r.resetTypes(ctx, sys)
|
||||
res["boot_targets"] = strList(boot["BootSourceOverrideTarget@Redfish.AllowableValues"])
|
||||
res["boot_mode"] = str(boot["BootSourceOverrideMode"])
|
||||
b, _ := json.Marshal(res)
|
||||
return string(b), nil
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// vendorBMC behaves the way one vendor's documentation (or the field notes in
|
||||
// Ironic, MAAS, bmclib) says it does; each flag is one such behaviour.
|
||||
type vendorBMC struct {
|
||||
mu sync.Mutex
|
||||
vendor, manufacturer string
|
||||
oem []string
|
||||
system string
|
||||
trailingSlash bool // collection members end in "/" (iLO)
|
||||
allowed, targets []string
|
||||
mode, power, etag string
|
||||
needIfMatch bool // 428 without If-Match (AMI)
|
||||
strongOnly bool // 412 for a weak If-Match
|
||||
futureSD bool // PATCH on the system: 400 OperationSupportedInFutureStateURI (ASRock Rack)
|
||||
refuseMode bool // 400 when the body carries BootSourceOverrideMode (HPE DL160)
|
||||
inPOST int // the first N PATCHes: 400 UnableToModifyDuringSystemPOST (HPE)
|
||||
flipsWithoutMode bool // a PATCH without the mode switches to Legacy (Supermicro)
|
||||
async bool // reset answers 202 and a task monitor
|
||||
sessionOnly bool // basic auth refused: log in through the SessionService (iBMC clients)
|
||||
actionInfo bool // reset values only through @Redfish.ActionInfo (iBMC)
|
||||
numericETag bool // ETag only as a bare number in @odata.etag (iRMC)
|
||||
chassisReset bool // no ComputerSystem.Reset; Chassis/1 has the reset (KAYTUS SDK)
|
||||
successAs400 bool // a working PATCH answers 400 "completed successfully" (KAYTUS SDK)
|
||||
refuseDisabled bool // Enabled "Disabled" is 400; clearing is Target None (iRMC)
|
||||
sessions int // open sessions
|
||||
|
||||
patchPaths, ifMatch, resets []string
|
||||
patches []obj
|
||||
taskPolls int
|
||||
}
|
||||
|
||||
func (f *vendorBMC) fail(w http.ResponseWriter, code int, id, msg string) {
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(obj{"error": obj{"code": "Base.1.0.GeneralError", "message": "see ExtendedInfo",
|
||||
"@Message.ExtendedInfo": []obj{{"MessageId": id, "Message": msg}}}})
|
||||
}
|
||||
|
||||
func (f *vendorBMC) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if r.URL.Path == "/redfish/v1/SessionService/Sessions" && r.Method == "POST" {
|
||||
var c struct{ UserName, Password string }
|
||||
_ = json.NewDecoder(r.Body).Decode(&c)
|
||||
if c.UserName != "admin" || c.Password != "secret" {
|
||||
f.fail(w, 401, "Base.1.0.NoValidSession", "bad credentials")
|
||||
return
|
||||
}
|
||||
f.sessions++
|
||||
w.Header().Set("X-Auth-Token", "tok")
|
||||
w.Header().Set("Location", "/redfish/v1/SessionService/Sessions/1")
|
||||
w.WriteHeader(201)
|
||||
return
|
||||
}
|
||||
authed := r.Header.Get("X-Auth-Token") == "tok"
|
||||
if u, p, ok := r.BasicAuth(); !f.sessionOnly && ok && u == "admin" && p == "secret" {
|
||||
authed = true
|
||||
}
|
||||
if r.URL.Path != "/redfish/v1/" && !authed {
|
||||
f.fail(w, 401, "Base.1.0.InsufficientPrivilege", "bad credentials")
|
||||
return
|
||||
}
|
||||
oem := obj{}
|
||||
for _, k := range f.oem {
|
||||
oem[k] = obj{}
|
||||
}
|
||||
sys := strings.TrimSuffix(r.URL.Path, "/")
|
||||
switch {
|
||||
case r.URL.Path == "/redfish/v1/":
|
||||
_ = json.NewEncoder(w).Encode(obj{"RedfishVersion": "1.15.0", "Vendor": f.vendor, "Oem": oem})
|
||||
case r.URL.Path == "/redfish/v1/SessionService/Sessions/1" && r.Method == "DELETE":
|
||||
f.sessions--
|
||||
w.WriteHeader(204)
|
||||
case r.URL.Path == "/redfish/v1/Systems/1/ResetActionInfo":
|
||||
_ = json.NewEncoder(w).Encode(obj{"Parameters": []obj{{"Name": "ResetType", "AllowableValues": f.allowed}}})
|
||||
case r.URL.Path == "/redfish/v1/Chassis/1" && r.Method == "GET":
|
||||
_ = json.NewEncoder(w).Encode(obj{"Actions": obj{"#Chassis.Reset": obj{"target": "/redfish/v1/Chassis/1/Actions/Chassis.Reset", "ResetType@Redfish.AllowableValues": f.allowed}}})
|
||||
case r.URL.Path == "/redfish/v1/Systems":
|
||||
m := f.system
|
||||
if f.trailingSlash {
|
||||
m += "/"
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(obj{"Members": []obj{{"@odata.id": m}}})
|
||||
case (sys == f.system || sys == f.system+"/SD") && r.Method == "GET":
|
||||
if f.etag != "" && !f.numericETag {
|
||||
w.Header().Set("ETag", f.etag)
|
||||
}
|
||||
boot := obj{"BootSourceOverrideTarget@Redfish.AllowableValues": f.targets, "BootSourceOverrideEnabled": "Disabled"}
|
||||
if f.mode != "" {
|
||||
boot["BootSourceOverrideMode"] = f.mode
|
||||
}
|
||||
reset := obj{"target": f.system + "/Actions/ComputerSystem.Reset", "ResetType@Redfish.AllowableValues": f.allowed}
|
||||
if f.actionInfo {
|
||||
reset = obj{"target": f.system + "/Actions/ComputerSystem.Reset", "@Redfish.ActionInfo": "/redfish/v1/Systems/1/ResetActionInfo"}
|
||||
}
|
||||
res := obj{"PowerState": f.power, "Manufacturer": f.manufacturer, "Model": "Test", "Boot": boot,
|
||||
"Actions": obj{"#ComputerSystem.Reset": reset}, "Links": obj{"Chassis": []obj{{"@odata.id": "/redfish/v1/Chassis/1"}}}}
|
||||
if f.chassisReset {
|
||||
res["Actions"] = obj{}
|
||||
}
|
||||
if f.numericETag {
|
||||
res["@odata.etag"] = json.Number(f.etag)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
case (sys == f.system || sys == f.system+"/SD") && r.Method == "PATCH":
|
||||
im := r.Header.Get("If-Match")
|
||||
f.patchPaths, f.ifMatch = append(f.patchPaths, sys), append(f.ifMatch, im)
|
||||
var body obj
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
boot := asObj(body["Boot"])
|
||||
switch {
|
||||
case f.needIfMatch && im == "":
|
||||
f.fail(w, 428, "Ami.1.0.PreconditionHeaderMissing", "If-Match header is required")
|
||||
case f.strongOnly && strings.HasPrefix(im, "W/"):
|
||||
f.fail(w, 412, "HttpStatus.1.0.PreconditionFailed", "ETag does not match")
|
||||
case f.futureSD && sys == f.system:
|
||||
f.fail(w, 400, "Ami.1.0.OperationSupportedInFutureStateURI", "moved to FutureState URI(/redfish/v1/Systems/Self/SD)")
|
||||
case f.inPOST > 0:
|
||||
f.inPOST--
|
||||
f.fail(w, 400, "iLO.2.14.UnableToModifyDuringSystemPOST", "")
|
||||
case f.refuseDisabled && boot["BootSourceOverrideEnabled"] == "Disabled":
|
||||
f.fail(w, 400, "Base.1.0.PropertyValueNotInList", "Disabled")
|
||||
case f.refuseMode && boot["BootSourceOverrideMode"] != nil:
|
||||
f.fail(w, 400, "Base.1.0.PropertyNotWritable", "BootSourceOverrideMode")
|
||||
default:
|
||||
if boot["BootSourceOverrideTarget"] != nil && !hasTag(f.targets, str(boot["BootSourceOverrideTarget"])) {
|
||||
f.fail(w, 400, "Base.1.0.PropertyValueNotInList", "target")
|
||||
return
|
||||
}
|
||||
if m := str(boot["BootSourceOverrideMode"]); m != "" {
|
||||
f.mode = m
|
||||
} else if f.flipsWithoutMode {
|
||||
f.mode = "Legacy"
|
||||
}
|
||||
f.patches = append(f.patches, boot)
|
||||
if f.successAs400 {
|
||||
f.fail(w, 400, "Base.1.0.Success", "The request completed successfully.")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
case r.URL.Path == f.system+"/Actions/ComputerSystem.Reset" || (f.chassisReset && r.URL.Path == "/redfish/v1/Chassis/1/Actions/Chassis.Reset"):
|
||||
var body struct{ ResetType string }
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if !hasTag(f.allowed, body.ResetType) {
|
||||
f.fail(w, 400, "Base.1.0.ActionParameterNotSupported", "ResetType")
|
||||
return
|
||||
}
|
||||
f.resets = append(f.resets, body.ResetType)
|
||||
switch body.ResetType {
|
||||
case "On", "ForceOn":
|
||||
f.power = "On"
|
||||
case "ForceOff", "GracefulShutdown":
|
||||
f.power = "Off"
|
||||
}
|
||||
if f.async {
|
||||
w.Header().Set("Location", "/redfish/v1/TaskService/Tasks/7")
|
||||
w.WriteHeader(202)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
case r.URL.Path == "/redfish/v1/TaskService/Tasks/7":
|
||||
f.taskPolls++
|
||||
st := "Running"
|
||||
if f.taskPolls >= 2 {
|
||||
st = "Completed"
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(obj{"TaskState": st})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func runBMC(t *testing.T, f *vendorBMC, driver string) (*bmcCreds, func()) {
|
||||
t.Helper()
|
||||
old := rfSleep
|
||||
rfSleep = func(context.Context, time.Duration) error { return nil }
|
||||
srv := httptest.NewTLSServer(f.handler())
|
||||
return creds(srv.URL, driver), func() { srv.Close(); rfSleep = old }
|
||||
}
|
||||
|
||||
func mustRun(t *testing.T, c *bmcCreds, op, arg string) string {
|
||||
t.Helper()
|
||||
out, err := powerRun(context.Background(), "", c, op, arg)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", op, arg, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var stdTargets = []string{"None", "Pxe", "Hdd", "Cd", "BiosSetup", "UefiHttp"}
|
||||
|
||||
func TestMatchRedfishProfile(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
vendor, manufacturer string
|
||||
oem []string
|
||||
want string
|
||||
}{
|
||||
{"Dell", "Dell Inc.", []string{"Dell"}, "idrac"},
|
||||
{"HPE", "HPE", []string{"Hpe"}, "ilo"},
|
||||
{"Lenovo", "Lenovo", nil, "xcc"},
|
||||
{"Supermicro", "Supermicro", []string{"Supermicro"}, "supermicro"},
|
||||
{"", "GIGA-BYTE TECHNOLOGY CO., LTD", []string{"Ami"}, "gigabyte"},
|
||||
{"AsrockRack", "", []string{"Ami"}, "asrockrack"},
|
||||
{"", "To Be Filled By O.E.M.", []string{"Ami"}, "ami"},
|
||||
{"OpenBMC", "", nil, "openbmc"},
|
||||
{"Contoso", "Contoso", nil, "redfish"},
|
||||
} {
|
||||
if got := matchRedfishProfile(c.vendor, c.manufacturer, c.oem).Driver; got != c.want {
|
||||
t.Errorf("%q/%q/%v: %s, want %s", c.vendor, c.manufacturer, c.oem, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Supermicro: a boot override without the mode can switch the board to Legacy.
|
||||
func TestRedfishSupermicroKeepsBootMode(t *testing.T) {
|
||||
f := &vendorBMC{vendor: "Supermicro", manufacturer: "Supermicro", system: "/redfish/v1/Systems/1", mode: "UEFI", power: "On",
|
||||
targets: stdTargets, flipsWithoutMode: true, etag: `"1"`,
|
||||
allowed: []string{"On", "ForceOff", "GracefulShutdown", "GracefulRestart", "ForceRestart", "Nmi", "ForceOn", "PowerCycle"}}
|
||||
c, done := runBMC(t, f, "supermicro")
|
||||
defer done()
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if f.mode != "UEFI" || f.patches[0]["BootSourceOverrideMode"] != "UEFI" || f.patches[0]["BootSourceOverrideEnabled"] != "Once" || f.ifMatch[0] != `"1"` {
|
||||
t.Errorf("mode now %s, patch %v, if-match %v", f.mode, f.patches, f.ifMatch)
|
||||
}
|
||||
mustRun(t, c, "cycle", "")
|
||||
if f.resets[0] != "PowerCycle" {
|
||||
t.Errorf("resets %v", f.resets)
|
||||
}
|
||||
}
|
||||
|
||||
// Dell: changing the mode starts a BIOS job; it is not sent.
|
||||
func TestRedfishIDRACOmitsBootMode(t *testing.T) {
|
||||
f := &vendorBMC{vendor: "Dell", manufacturer: "Dell Inc.", system: "/redfish/v1/Systems/System.Embedded.1", mode: "UEFI", power: "Off",
|
||||
targets: stdTargets, allowed: []string{"On", "ForceOff", "ForceRestart", "GracefulShutdown", "PushPowerButton", "Nmi"}}
|
||||
c, done := runBMC(t, f, "idrac")
|
||||
defer done()
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if _, sent := f.patches[0]["BootSourceOverrideMode"]; sent {
|
||||
t.Errorf("idrac got the mode: %v", f.patches[0])
|
||||
}
|
||||
c.Extra["boot_mode"] = "Legacy" // the operator asks: then it goes
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if f.patches[1]["BootSourceOverrideMode"] != "Legacy" {
|
||||
t.Errorf("asked mode not sent: %v", f.patches[1])
|
||||
}
|
||||
mustRun(t, c, "on", "")
|
||||
mustRun(t, c, "cycle", "") // no PowerCycle on iDRAC9 3.x: ForceRestart
|
||||
if strings.Join(f.resets, ",") != "On,ForceRestart" {
|
||||
t.Errorf("resets %v", f.resets)
|
||||
}
|
||||
}
|
||||
|
||||
// HPE: no boot changes during POST; some refuse the mode; members end in "/".
|
||||
func TestRedfishILOPostStateAndMode(t *testing.T) {
|
||||
f := &vendorBMC{vendor: "HPE", manufacturer: "HPE", oem: []string{"Hpe"}, system: "/redfish/v1/Systems/1", trailingSlash: true,
|
||||
mode: "UEFI", power: "On", targets: stdTargets, inPOST: 2, refuseMode: true, etag: `W/"9"`,
|
||||
allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart", "Nmi", "PushPowerButton", "GracefulRestart"}}
|
||||
c, done := runBMC(t, f, "redfish") // generic driver, found through the collection
|
||||
defer done()
|
||||
out := mustRun(t, c, "bootdev", "pxe")
|
||||
if len(f.patches) != 1 || f.patches[0]["BootSourceOverrideTarget"] != "Pxe" || f.patches[0]["BootSourceOverrideMode"] != nil {
|
||||
t.Errorf("%s: patches %v", out, f.patches)
|
||||
}
|
||||
var p obj
|
||||
_ = json.Unmarshal([]byte(mustRun(t, c, "probe", "")), &p)
|
||||
if p["driver"] != "ilo" || p["system"] != "/redfish/v1/Systems/1" || p["boot_mode"] != "UEFI" {
|
||||
t.Errorf("probe %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// AMI (Gigabyte): Systems/Self, If-Match required, a short ResetType list.
|
||||
func TestRedfishAMIIfMatchAndCycle(t *testing.T) {
|
||||
f := &vendorBMC{manufacturer: "GIGA-BYTE TECHNOLOGY CO., LTD", oem: []string{"Ami"}, system: "/redfish/v1/Systems/Self",
|
||||
mode: "UEFI", power: "On", targets: stdTargets, needIfMatch: true, strongOnly: true, etag: `W/"abc"`,
|
||||
allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart"}}
|
||||
c, done := runBMC(t, f, "gigabyte")
|
||||
defer done()
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if f.ifMatch[len(f.ifMatch)-1] != `"abc"` || f.patches[0]["BootSourceOverrideMode"] != "UEFI" {
|
||||
t.Errorf("if-match tries %v, patch %v", f.ifMatch, f.patches)
|
||||
}
|
||||
mustRun(t, c, "cycle", "")
|
||||
if strings.Join(f.resets, ",") != "ForceRestart" {
|
||||
t.Errorf("resets %v", f.resets)
|
||||
}
|
||||
// a build that offers neither PowerCycle nor a restart: off, then on
|
||||
f.allowed = []string{"On", "ForceOff"}
|
||||
f.resets = nil
|
||||
mustRun(t, c, "cycle", "")
|
||||
if strings.Join(f.resets, ",") != "ForceOff,On" {
|
||||
t.Errorf("emulated cycle %v", f.resets)
|
||||
}
|
||||
}
|
||||
|
||||
// ASRock Rack: boot settings live in the future-state resource .../SD.
|
||||
func TestRedfishASRockFutureState(t *testing.T) {
|
||||
f := &vendorBMC{vendor: "AsrockRack", oem: []string{"Ami"}, system: "/redfish/v1/Systems/Self", mode: "UEFI", power: "Off",
|
||||
targets: stdTargets, futureSD: true, etag: `"5"`, allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart"}}
|
||||
c, done := runBMC(t, f, "asrockrack")
|
||||
defer done()
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if strings.Join(f.patchPaths, ",") != "/redfish/v1/Systems/Self,/redfish/v1/Systems/Self/SD" || len(f.patches) != 1 {
|
||||
t.Errorf("paths %v patches %v", f.patchPaths, f.patches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedfishTaskAndTargets(t *testing.T) {
|
||||
f := &vendorBMC{vendor: "Contoso", system: "/redfish/v1/Systems/1", power: "Off", targets: []string{"None", "Pxe", "Hdd"},
|
||||
async: true, allowed: []string{"On", "ForceOff"}}
|
||||
c, done := runBMC(t, f, "redfish")
|
||||
defer done()
|
||||
mustRun(t, c, "on", "")
|
||||
if f.taskPolls < 2 {
|
||||
t.Errorf("task polled %d times", f.taskPolls)
|
||||
}
|
||||
if _, err := powerRun(context.Background(), "", c, "bootdev", "bios"); err == nil || !strings.Contains(err.Error(), "offers boot targets") {
|
||||
t.Errorf("an unoffered target: %v", err)
|
||||
}
|
||||
mustRun(t, c, "bootdev", "none")
|
||||
if f.patches[0]["BootSourceOverrideEnabled"] != "Disabled" {
|
||||
t.Errorf("clear %v", f.patches)
|
||||
}
|
||||
}
|
||||
|
||||
// iBMC: sessions, ActionInfo, ForcePowerCycle, If-Match on PATCH.
|
||||
func TestRedfishXFusionSession(t *testing.T) {
|
||||
f := &vendorBMC{oem: []string{"xFusion"}, manufacturer: "xFusion", system: "/redfish/v1/Systems/1", power: "On", mode: "UEFI",
|
||||
targets: stdTargets, sessionOnly: true, actionInfo: true, needIfMatch: true, etag: `W/"77"`,
|
||||
allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart", "Nmi", "ForcePowerCycle"}}
|
||||
c, done := runBMC(t, f, "xfusion")
|
||||
defer done()
|
||||
mustRun(t, c, "cycle", "")
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if strings.Join(f.resets, ",") != "ForcePowerCycle" || len(f.patches) != 1 || f.ifMatch[0] != `W/"77"` || f.sessions != 0 {
|
||||
t.Errorf("resets %v patches %v if-match %v open sessions %d", f.resets, f.patches, f.ifMatch, f.sessions)
|
||||
}
|
||||
var p obj
|
||||
_ = json.Unmarshal([]byte(mustRun(t, c, "probe", "")), &p)
|
||||
if p["driver"] != "xfusion" || p["auth"] != "session" || f.sessions != 0 {
|
||||
t.Errorf("probe %v, sessions %d", p, f.sessions)
|
||||
}
|
||||
}
|
||||
|
||||
// iRMC: the ETag only in the body as a number; never Enabled Disabled.
|
||||
func TestRedfishIRMC(t *testing.T) {
|
||||
f := &vendorBMC{vendor: "ts_fujitsu", manufacturer: "FUJITSU", system: "/redfish/v1/Systems/0", power: "On", mode: "UEFI",
|
||||
targets: stdTargets, numericETag: true, etag: "1714", needIfMatch: true, refuseDisabled: true,
|
||||
allowed: []string{"ForceOff", "ForceRestart", "Nmi", "PushPowerButton"}}
|
||||
c, done := runBMC(t, f, "irmc")
|
||||
defer done()
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if f.ifMatch[0] != "1714" || f.patches[0]["BootSourceOverrideMode"] != nil {
|
||||
t.Errorf("if-match %v patch %v", f.ifMatch, f.patches)
|
||||
}
|
||||
mustRun(t, c, "bootdev", "none")
|
||||
if f.patches[1]["BootSourceOverrideTarget"] != "None" || f.patches[1]["BootSourceOverrideEnabled"] != nil {
|
||||
t.Errorf("clear %v", f.patches[1])
|
||||
}
|
||||
mustRun(t, c, "soft", "") // no GracefulShutdown: the power button
|
||||
if strings.Join(f.resets, ",") != "PushPowerButton" {
|
||||
t.Errorf("resets %v", f.resets)
|
||||
}
|
||||
}
|
||||
|
||||
// KAYTUS (third-party SDK): reset on the chassis; a PATCH that worked answers 400.
|
||||
func TestRedfishKaytus(t *testing.T) {
|
||||
f := &vendorBMC{oem: []string{"Public"}, manufacturer: "KAYTUS", system: "/redfish/v1/Systems/1", power: "Off", mode: "UEFI",
|
||||
targets: stdTargets, chassisReset: true, successAs400: true,
|
||||
allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart", "PowerCycle"}}
|
||||
c, done := runBMC(t, f, "redfish")
|
||||
defer done()
|
||||
mustRun(t, c, "on", "")
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if strings.Join(f.resets, ",") != "On" || len(f.patches) != 1 {
|
||||
t.Errorf("resets %v patches %v", f.resets, f.patches)
|
||||
}
|
||||
if p := matchRedfishProfile("", "KAYTUS", []string{"Public"}); p.Driver != "kaytus" {
|
||||
t.Errorf("profile %s", p.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
// CIMC: /Systems/<serial>, found through the collection.
|
||||
func TestRedfishCIMCSerialPath(t *testing.T) {
|
||||
f := &vendorBMC{manufacturer: "Cisco Systems Inc", oem: []string{"Cisco"}, system: "/redfish/v1/Systems/WZP21330G5B", power: "On",
|
||||
targets: stdTargets, allowed: []string{"On", "ForceOff", "GracefulShutdown", "GracefulRestart", "ForceRestart", "Nmi", "PowerCycle"}}
|
||||
c, done := runBMC(t, f, "cimc")
|
||||
defer done()
|
||||
mustRun(t, c, "cycle", "")
|
||||
if strings.Join(f.resets, ",") != "PowerCycle" {
|
||||
t.Errorf("resets %v", f.resets)
|
||||
}
|
||||
}
|
||||
@@ -496,6 +496,7 @@ function pageMachine(d, tab) {
|
||||
} else if (tab === 'bmc') {
|
||||
body = `<div class="actions" style="margin-bottom:12px"><button class="btn primary" id="bmc-edit">Edit BMC…</button><button class="btn" id="bmc-probe" title="ask the BMC whether it speaks Redfish with the stored account">Detect Redfish</button><span class="muted">Commands run on rack controller ${esc(s.rack_controller || '')}</span></div>
|
||||
<div class="grid">${card('BMC', kv([['Driver', `<b>${esc(b.driver || 'ipmi')}</b>${b.driver_set ? ' <span class="muted">(set by operator)</span>' : ''}${b.redfish ? ` · Redfish ${esc(b.redfish.redfish)} ${esc(b.redfish.manufacturer || '')} ${esc(b.redfish.model || '')}` : ''}`],
|
||||
['Redfish offers', b.redfish && (b.redfish.reset_types || b.redfish.boot_targets) ? `<span class="mono">${esc((b.redfish.reset_types || []).join(' '))}</span><br><span class="muted">boot</span> <span class="mono">${esc((b.redfish.boot_targets || []).join(' '))}</span>${b.redfish.boot_mode ? ` · mode ${esc(b.redfish.boot_mode)}` : ''}${b.redfish.profile ? `<br><span class="muted">profile ${esc(b.redfish.profile)}</span>` : ''}` : ''],
|
||||
['Settings', b.extra && Object.keys(b.extra).length ? `<span class="mono">${esc(Object.entries(b.extra).map(([k, v]) => k + '=' + v).join(' '))}</span>` : ''],
|
||||
['Source', esc(b.source)], ['Address', `<span class="mono">${esc(b.address)}${b.port && b.port !== 623 ? ':' + esc(b.port) : ''}</span>`], ['MAC', `<span class="mono">${esc(b.mac)}</span>`], ['User', esc(b.user)], ['Password', d.has_bmc_secret ? '<span class="ok">stored encrypted on the server</span>' : '<span class="bad">none</span>'], ['Configured', b.configured ? '<span class="ok">yes</span>' : '<span class="bad">no</span>'], ['Verified out-of-band', b.verified_oob === true ? '<span class="ok">yes</span>' : b.verified_oob === false ? '<span class="bad">no</span>' : ''], ['Channel', esc(b.channel)], ['LAN', b.lan ? `<span class="mono">${esc(JSON.stringify(b.lan))}</span>` : ''], ['Firmware', esc((b.mc_info || {})['Firmware Revision'])], ['Manufacturer', esc((b.mc_info || {})['Manufacturer Name'])], ['Errors', (b.errors || []).length ? `<span class="bad">${esc(b.errors.join('; '))}</span>` : '']]))}
|
||||
${(b.users_before || []).length ? card('User table before commissioning', table(['ID', 'Name', 'Priv', 'IPMI'], b.users_before.map(u => [esc(u.id), esc(u.name), esc(u.priv), u.ipmi_msg ? 'yes' : 'no']))) : ''}
|
||||
@@ -777,15 +778,16 @@ function wireNetwork() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- BMC driver settings
|
||||
const powerDriverNames = [['ipmi', 'IPMI 2.0 (ipmitool)'], ['redfish', 'Redfish, any vendor'], ['idrac', 'Dell iDRAC 8/9'], ['ilo', 'HPE iLO 4/5/6'], ['supermicro', 'Supermicro (Redfish)'], ['xcc', 'Lenovo XClarity'], ['openbmc', 'OpenBMC'], ['proxmox', 'Proxmox VE VM'], ['manual', 'manual (no power control)']];
|
||||
async function editBMC(id, b) {
|
||||
// the server's list: one entry per vendor profile it knows
|
||||
const drivers = await api('GET', '/api/v1/power-drivers').then(ds => ds.map(d => [d.name, d.description]), () => [['ipmi', 'IPMI 2.0'], ['redfish', 'Redfish']]);
|
||||
const v = await dialog('BMC settings', [
|
||||
{ name: 'driver', label: 'Driver', type: 'select', value: b.driver || 'ipmi', options: powerDriverNames },
|
||||
{ name: 'driver', label: 'Driver', type: 'select', value: b.driver || 'ipmi', options: drivers },
|
||||
{ name: 'address', label: 'Address', value: b.address || '', placeholder: '10.0.0.5, bmc.example or https://host:8443' },
|
||||
{ name: 'port', label: 'Port', value: b.port ? String(b.port) : '', placeholder: "driver's default" },
|
||||
{ name: 'user', label: 'User', value: b.user || '', help: 'proxmox: token id, e.g. root@pam!rollcall' },
|
||||
{ name: 'password', label: 'Password', placeholder: 'leave empty to keep the stored one', help: 'proxmox: the token secret' },
|
||||
{ name: 'extra', label: 'Settings', type: 'textarea', rows: 3, value: Object.entries(b.extra || {}).map(([k, x]) => `${k}=${x}`).join('\n'), placeholder: 'system=/redfish/v1/Systems/1\nverify_tls=true\ncipher=17\nnode=pve1\nvmid=211', help: 'one KEY=VALUE per line; a line KEY= removes it' },
|
||||
{ name: 'extra', label: 'Settings', type: 'textarea', rows: 3, value: Object.entries(b.extra || {}).map(([k, x]) => `${k}=${x}`).join('\n'), placeholder: 'system=/redfish/v1/Systems/1\nboot_mode=UEFI\nverify_tls=true\ncipher=17\nnode=pve1\nvmid=211', help: 'one KEY=VALUE per line; a line KEY= removes it' },
|
||||
{ name: 'verify', label: 'Check with a status call before saving', type: 'checkbox', value: true },
|
||||
], 'The password is sealed on the region and only travels to the rack controller over mutual TLS.', 'Save');
|
||||
if (!v) return;
|
||||
|
||||
Reference in new issue
Block a user