Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
12686c8
Initial support basis
izvyk Feb 4, 2026
ce9721f
Improve error handling and fix stuff
izvyk Feb 4, 2026
0a6cd30
Allow setting ChassisID per vendor
izvyk Feb 4, 2026
0667d83
Finish outband implementation
izvyk Feb 5, 2026
62815df
Umcomment inband implementation
izvyk Feb 5, 2026
9aa5ff1
Fix a typo
izvyk Feb 5, 2026
756107c
Merge branch 'master' into 83-fujitsu-support
izvyk Feb 5, 2026
382a6a9
Add automatic SystemID/ChassisID discovery to make the code more robust
izvyk Feb 5, 2026
fd2f577
Remove unnecessary code
izvyk Feb 5, 2026
cc98d0d
Don't use ETags if not necessary
izvyk Feb 5, 2026
bc9953b
Adjust usernames
izvyk Feb 5, 2026
845ef3d
Adjust password constraints to fix user creation
izvyk Feb 10, 2026
6b1c48c
Adjust BMC channel number
izvyk Feb 11, 2026
32808a8
Fix PowerCycle() on Fujitsu
izvyk Feb 16, 2026
61d0c75
Drain http response body to allow golang to reuse the connection
izvyk Feb 16, 2026
5829d27
Fix Reset() methods by using the gofish fork with a fix (to be replac…
izvyk Feb 17, 2026
c15d121
Fix Reset() methods: use the upstream
izvyk Feb 17, 2026
40058f0
Fix RedFish permissions via IPMI inband command to fix user creation
izvyk Feb 19, 2026
0c999d3
Try serial console via IPMI
izvyk Feb 25, 2026
dfc7b83
Merge branch 'master' into 83-fujitsu-support
izvyk Apr 10, 2026
3702a94
Fix the bootTarget
izvyk Apr 10, 2026
972c752
Refactoring and fixes
izvyk Apr 10, 2026
d9fc035
Fix http response code check
izvyk Apr 12, 2026
bded598
Fix cancelled context issue for PowerCycle on Fujitsu
izvyk Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions connect/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"time"

"github.com/metal-stack/go-hal/internal/vendors/fujitsu"
"github.com/metal-stack/go-hal/internal/vendors/gigabyte"

"github.com/metal-stack/go-hal/internal/vendors/vagrant"
Expand Down Expand Up @@ -38,6 +39,8 @@ func InBand(log logger.Logger) (hal.InBand, error) {
return vagrant.InBand(b, log)
case api.VendorGigabyte:
return gigabyte.InBand(b, log)
case api.VendorFujitsu:
return fujitsu.InBand(b, log)
case api.VendorDell, api.VendorUnknown:
fallthrough
default:
Expand Down Expand Up @@ -67,6 +70,8 @@ func OutBand(ip string, ipmiPort int, user, password string, log logger.Logger,
return vagrant.OutBand(b, ip, ipmiPort, user, password), nil
case api.VendorGigabyte:
return gigabyte.OutBand(r, b), nil
case api.VendorFujitsu:
return fujitsu.OutBand(r, b), nil
case api.VendorDell, api.VendorUnknown:
fallthrough
default:
Expand Down
4 changes: 2 additions & 2 deletions hal.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ type InBand interface {
// PowerCycle cycle the power state of the server
PowerCycle() error

// IdentifyLEDState get the identify LED state
// IdentifyLEDState set the identify LED state
IdentifyLEDState(IdentifyLEDState) error
// IdentifyLEDOn set the identify LED to on
IdentifyLEDOn() error
Expand Down Expand Up @@ -157,7 +157,7 @@ type OutBand interface {
// PowerCycle cycle the power state of the server
PowerCycle() error

// IdentifyLEDState get the identify LED state
// IdentifyLEDState set the identify LED state
IdentifyLEDState(IdentifyLEDState) error
// IdentifyLEDOn set the identify LED to on
IdentifyLEDOn() error
Expand Down
159 changes: 148 additions & 11 deletions internal/redfish/redfish.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

Expand All @@ -24,9 +25,11 @@ type APIClient struct {
urlPrefix string
user string
password string
basicAuth string
basicAuth string // TODO Why do we need this? Seems to be never used
log logger.Logger
connectionTimeout time.Duration
chassisID string
systemID string
}

type bootOverrideRequest struct {
Expand Down Expand Up @@ -58,7 +61,8 @@ func New(url, user, password string, insecure bool, log logger.Logger, connectio
if err != nil {
return nil, err
}
return &APIClient{

apiClient := &APIClient{
client: c,
Client: c.HTTPClient,
user: user,
Expand All @@ -67,7 +71,90 @@ func New(url, user, password string, insecure bool, log logger.Logger, connectio
urlPrefix: fmt.Sprintf("%s/redfish/v1", url),
log: log,
connectionTimeout: timeout,
}, nil
chassisID: "",
systemID: "",
}

// Discover systemID and chassisID
if err := apiClient.discoverIDs(ctx); err != nil {
log.Warnw("failed to auto-discover system/chassis ID, using defaults", "error", err)
}

if apiClient.systemID == "" {
apiClient.systemID = "Self" // Default SystemID to ensure backwards compatibility
}
if apiClient.chassisID == "" {
apiClient.chassisID = "1" // Default ChassisID to ensure backwards compatibility
}

return apiClient, nil
}

// discoverIDs attempts to automatically discover the systemID and chassisID
func (c *APIClient) discoverIDs(ctx context.Context) error {
g := c.client.WithContext(ctx)

if g.Service == nil {
return fmt.Errorf("gofish service root is not available")
}

// Discover System ID
systems, err := g.Service.Systems()
if err != nil {
return fmt.Errorf("failed to query systems: %w", err)
}

if len(systems) > 0 {
// Use the ID from the first system
c.systemID = systems[0].ID
c.log.Debugw("discovered system ID", "systemID", c.systemID)
} else {
c.log.Warnw("no systems found during discovery")
}

// Discover Chassis ID
chassis, err := g.Service.Chassis()
if err != nil {
return fmt.Errorf("failed to query chassis: %w", err)
}

// Prefer RackMount chassis, but fall back to any chassis if none found
var selectedChassis *schemas.Chassis
for _, chass := range chassis {
if chass.ChassisType == schemas.RackMountChassisType {
selectedChassis = chass
break
}
}

// If no RackMount chassis found, use the first available
if selectedChassis == nil && len(chassis) > 0 {
selectedChassis = chassis[0]
c.log.Debugw("no RackMount chassis found, using first available",
"chassisType", selectedChassis.ChassisType)
}

if selectedChassis != nil {
c.chassisID = selectedChassis.ID
c.log.Debugw("discovered chassis ID", "chassisID", c.chassisID, "chassisType", selectedChassis.ChassisType)
} else {
c.log.Warnw("no chassis found during discovery")
}

// Error if we couldn't find either ID
if c.systemID == "" || c.chassisID == "" {
return fmt.Errorf("failed to discover any system or chassis IDs")
}

return nil
}

func (c *APIClient) SetChassisID(id string) {
c.chassisID = id
}

func (c *APIClient) SetSystemID(id string) {
c.systemID = id
}

func (c *APIClient) BoardInfo() (*api.Board, error) {
Expand Down Expand Up @@ -262,19 +349,22 @@ func (c *APIClient) SetChassisIdentifyLEDOn() error {
return err
}

req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/Chassis/1", c.urlPrefix), bytes.NewReader(body))
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/Chassis/%s", c.urlPrefix, c.chassisID), bytes.NewReader(body))
if err != nil {
return err
}
c.addHeadersAndAuth(req)

resp, err := c.Do(req)
resp, err := c.doWithETag(req)
if err == nil {
_ = resp.Body.Close()
}
if err != nil {
return fmt.Errorf("unable to turn on the chassis identify LED %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("unable to turn on the chassis identify LED, status code: %d", resp.StatusCode)
}
return nil
}

Expand All @@ -288,19 +378,22 @@ func (c *APIClient) SetChassisIdentifyLEDOff() error {
return err
}

req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/Chassis/1", c.urlPrefix), bytes.NewReader(body))
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/Chassis/%s", c.urlPrefix, c.chassisID), bytes.NewReader(body))
if err != nil {
return err
}
c.addHeadersAndAuth(req)

resp, err := c.Do(req)
resp, err := c.doWithETag(req)
if err == nil {
_ = resp.Body.Close()
}
if err != nil {
return fmt.Errorf("unable to turn off the chassis identify LED %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("unable to turn off the chassis identify LED, status code: %d", resp.StatusCode)
}
return nil
}

Expand Down Expand Up @@ -344,27 +437,30 @@ func (c *APIClient) setBootOrderOverride(payload bootOverrideRequest) error {
if err != nil {
return err
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/Systems/Self", c.urlPrefix), bytes.NewReader(body))
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/Systems/%s", c.urlPrefix, c.systemID), bytes.NewReader(body))
if err != nil {
return err
}
c.addHeadersAndAuth(req)

resp, err := c.Do(req)
resp, err := c.doWithETag(req)
if err == nil {
// TODO drain body?
_ = resp.Body.Close()
}
if err != nil {
return fmt.Errorf("unable to override boot order %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("unable to override boot order, status code: %d", resp.StatusCode)
}

return nil
}

func (c *APIClient) addHeadersAndAuth(req *http.Request) {
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", "Basic "+c.basicAuth)
req.Header.Add("If-Match", "*")
req.Header.Set("Accept", "application/json")
req.SetBasicAuth(c.user, c.password)
}

Expand Down Expand Up @@ -416,3 +512,44 @@ func (c *APIClient) BMC() (*api.BMC, error) {

return bmc, nil
}

// TODO should we cache this?
func (c *APIClient) getETag(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
c.addHeadersAndAuth(req)

resp, err := c.Do(req)
if err != nil {
return "", err
}
defer func() {
_ = resp.Body.Close()
}()

// Drain the body to ensure the connection can be reused
_, _ = io.Copy(io.Discard, resp.Body)

if resp.StatusCode != http.StatusOK {
c.log.Warnw("failed to get etag, defaulting to wildcard", "status", resp.StatusCode, "url", url)
return "*", nil
}

etag := resp.Header.Get("ETag")
if etag == "" {
return "*", nil
}
return etag, nil
}

func (c *APIClient) doWithETag(req *http.Request) (*http.Response, error) {
etag, err := c.getETag(req.Context(), req.URL.String())
if err != nil {
return nil, fmt.Errorf("failed to get ETag: %w", err)
}

req.Header.Set("If-Match", etag)
return c.Do(req)
}
Loading