Redfish vendor profiles from public documentation
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 37s

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:
Ilker ManapandClaude Opus 5.5 committed 2026-10-29 18:52:13 +01:00
1 parent a549f01ef5
commit 3568c4866e
6 files changed
+1293 -297

No files matched your search

+10
View File
@@ -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
+55 -4
View File
@@ -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
View File
@@ -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
+812
View File
@@ -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
}
+406
View File
@@ -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)
}
}
+5 -3
View File
@@ -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;