Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions bare-metal-fulfillment-operator/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ bare-metal-fulfillment-operator/
│ ├── main.go # Operator entry point
│ └── main_test.go # Entry point tests
├── internal/
│ ├── bmcdiscovery/ # BMC address discovery (Redfish system path, protocol classification, target validation)
│ ├── controller/ # Reconciliation logic (pool + instance controllers)
│ ├── helpers/ # Utility functions
│ ├── inventory/ # BareMetalInstance's host inventory abstraction (pluggable backend interface)
Expand Down Expand Up @@ -109,6 +110,7 @@ Management Client (Ironic)

| Package | Purpose |
|---------|---------|
| `internal/bmcdiscovery/` | BMC address discovery: protocol classification from interface names, Redfish system path discovery via MAC matching, BMC target validation |
| `internal/controller/` | Pool and instance reconciliation (lifecycle, finalizers, status updates) |
| `internal/inventory/` | Host allocation abstraction with pluggable backend interface (OpenStack, Metal3) and in-memory locking |
| `internal/management/` | Power control via OpenStack Ironic integration |
Expand Down
1 change: 1 addition & 0 deletions bare-metal-fulfillment-operator/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/onsi/gomega v1.42.1
github.com/osac-project/osac/osac-operator v0.0.10
github.com/osac-project/osac/osac-operator/api v0.0.7
github.com/stmcginnis/gofish v0.24.0
k8s.io/api v0.36.3
k8s.io/apimachinery v0.36.3
k8s.io/client-go v0.36.3
Expand Down
2 changes: 2 additions & 0 deletions bare-metal-fulfillment-operator/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stmcginnis/gofish v0.24.0 h1:zaBBFNtdSFH/+lJju29HMDHU3suIR+YhygqoJYxW+2Q=
github.com/stmcginnis/gofish v0.24.0/go.mod h1:PzF5i8ecRG9A2ol8XT64npKUunyraJ+7t0kYMpQAtqU=
github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
Expand Down
173 changes: 173 additions & 0 deletions bare-metal-fulfillment-operator/internal/bmcdiscovery/bmcdiscovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
Copyright 2026.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

// Package bmcdiscovery discovers BMC addresses for bare-metal hosts.
// It classifies BMC protocols from interface names, discovers Redfish
// system paths via MAC-address matching, and constructs validated BMC
// URLs suitable for Metal3 BareMetalHost spec.bmc.address.
package bmcdiscovery

import (
"context"
"errors"
"fmt"
"net"
"regexp"
"strings"
)

var (
ErrNoBMCInterface = errors.New("no matching BMC interface found in device interfaces")
ErrUnsupportedBMCType = errors.New("unsupported BMC protocol type")
ErrNoMACMatch = errors.New("no Redfish system found matching boot MAC address")
ErrInvalidBMCTarget = errors.New("invalid BMC target")
)

// Protocol represents a BMC protocol type classified from a device
// interface name.
type Protocol string

const (
ProtocolRedfish Protocol = "redfish"
ProtocolIPMI Protocol = "ipmi"
ProtocolILO Protocol = "ilo"
ProtocolDRAC Protocol = "drac"
)

// DeviceInterface represents a network interface entry from a device's
// interface list. Only the fields needed for BMC discovery are included.
type DeviceInterface struct {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] api-shape

DeviceInterface struct lacks JSON tags. Sibling packages consistently use JSON tags on structs representing external system data. Whether tags are needed depends on the integration wiring in downstream PR #354.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] naming-trajectory

DeviceInterface defines own fields rather than reusing bcmclient types. Intentional per PR design, but integration wiring (PR #354) will need a mapping layer.

// ChildType is the interface type identifier used to distinguish
// BMC interfaces from other interface types (e.g. data network).
ChildType string
// Name encodes the BMC protocol (e.g. "rf0" for Redfish, "ipmi0"
// for IPMI, "ilo0" for iLO, "drac0" for iDRAC).
Name string
// IP is the BMC management interface IP address.
IP string
}

// BMCInfo holds the BMC connection information needed for address
// construction and Redfish discovery.
type BMCInfo struct {
IP string
Protocol Protocol
}

var bmcTypePatterns = map[Protocol]*regexp.Regexp{
ProtocolRedfish: regexp.MustCompile(`^rf\d+$`),
ProtocolIPMI: regexp.MustCompile(`^ipmi\d+$`),
ProtocolILO: regexp.MustCompile(`^ilo\d+$`),
ProtocolDRAC: regexp.MustCompile(`^drac\d+$`),
}

var redfishCompatiblePrefixes = map[Protocol]string{
ProtocolRedfish: "redfish-virtualmedia",
ProtocolDRAC: "idrac-virtualmedia",
ProtocolILO: "ilo5-virtualmedia",
}

func classifyProtocol(interfaceName string) (Protocol, error) {
name := strings.ToLower(interfaceName)
for protocol, pattern := range bmcTypePatterns {
if pattern.MatchString(name) {
return protocol, nil
}
}
return "", fmt.Errorf("%w: %q", ErrUnsupportedBMCType, interfaceName)
}

func isRedfishCompatible(p Protocol) bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] documentation-comments

Doc comments on unexported functions use capitalized/exported-style names (e.g. // IsRedfishCompatible for isRedfishCompatible). Go convention is that doc comments should start with the actual function name.

_, ok := redfishCompatiblePrefixes[p]
return ok
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] logic-error

IPv6 IPMI addresses fail validation. formatHost wraps IPv6 in brackets, but splitHostPort returns the bracketed string when no port is present. net.ParseIP rejects bracketed input, so all IPMI addresses with IPv6 BMC IPs fail validation.

Suggested fix: In splitHostPort, strip surrounding brackets when net.SplitHostPort returns an error. Add a test for ValidateBMCAddress("ipmi://[2001:db8::1]").

// ExtractBMCInfo scans a device's interfaces for the first entry whose
// ChildType matches childType, then classifies the BMC protocol from
// the interface name.
func ExtractBMCInfo(interfaces []DeviceInterface, childType string) (*BMCInfo, error) {
for _, iface := range interfaces {
if iface.ChildType != childType {
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] edge-case

IPv6 BMC addresses produce malformed URIs. BuildStaticAddress and BuildRedfishAddress use raw IP strings without brackets. For IPv6 addresses this produces non-RFC-3986-compliant URIs. ValidateBMCTarget accepts global-unicast IPv6, so these malformed URIs can reach downstream consumers (Metal3/Ironic).

Suggested fix: Detect IPv6 addresses (net.ParseIP(ip).To4() == nil) and wrap them in brackets for URI construction.

}
protocol, err := classifyProtocol(iface.Name)
if err != nil {
return nil, err
}
return &BMCInfo{
IP: iface.IP,
Protocol: protocol,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] edge-case

ExtractBMCInfo does not validate that the matched interface has a non-empty IP address. An empty DeviceInterface.IP produces an invalid BMC URL (e.g., 'ipmi://' with no host) that passes ValidateBMCAddress's scheme-only check. The error surfaces as an opaque BMH registration failure rather than a clear validation error at construction time.

Suggested fix: Validate iface.IP != "" in ExtractBMCInfo before returning, e.g.: if iface.IP == "" { return nil, fmt.Errorf("%w: interface %q has no IP address", ErrInvalidBMCTarget, iface.Name) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] injection-vuln

BMCInfo.IP is populated from DeviceInterface.IP without IP address validation. formatHost uses net.ParseIP only for IPv6 bracket wrapping; parse failure falls through to raw string interpolation. See also: [edge-case] finding at this location.

}, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] nil-deref

Resolve panics with nil discoverer on Redfish-compatible protocols. IPMI path is safe (returns before reaching discoverer call), but Redfish/iLO/iDRAC paths call discoverer.DiscoverSystemPath without nil guard.

Suggested fix: Add nil check: if discoverer == nil { return "", fmt.Errorf("discoverer is required for %s protocol", info.Protocol) }.

}
return nil, ErrNoBMCInterface
}

func formatHost(ip string) string {
parsed := net.ParseIP(ip)
if parsed != nil && parsed.To4() == nil {
return "[" + ip + "]"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] code-organization

formatHost calls net.ParseIP(ip) twice on the same input. Minor inefficiency; parse once and reuse the result.

}
return ip
}

func buildStaticAddress(bmcIP string) string {
return fmt.Sprintf("ipmi://%s", formatHost(bmcIP))
}

func buildRedfishAddress(bmcIP string, protocol Protocol, systemPath string) string {
prefix := redfishCompatiblePrefixes[protocol]
return fmt.Sprintf("%s+https://%s%s", prefix, formatHost(bmcIP), systemPath)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] edge-case

buildRedfishAddress concatenates systemPath without validating it starts with '/'. A missing leading slash produces a malformed URL.


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] input-validation

buildRedfishAddress interpolates systemPath from a remote BMC's ODataID response (untrusted network input) without validating it is a legitimate Redfish system path. ValidateBMCAddress validates scheme/host/port but not the path component.

Suggested fix: Consider validating that systemPath starts with /redfish/v1/Systems/ and contains only expected characters.

// Resolve constructs a validated BMC address from the given BMCInfo.
// For IPMI, it returns a static URL. For Redfish-compatible protocols
// (Redfish, iLO, iDRAC), it uses the provided Discoverer to find the
// system path via MAC-address matching.
//
// Callers that have raw device interface data can use ExtractBMCInfo
// to build the BMCInfo first.
func Resolve(
ctx context.Context,
info *BMCInfo,
bootMAC string,
username string,
password string,
discoverer Discoverer,
) (string, error) {
if !isRedfishCompatible(info.Protocol) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] edge-case

Resolve dereferences info.Protocol without a nil check. A nil *BMCInfo causes a panic. As an internal API where *BMCInfo is only constructed via ExtractBMCInfo, a nil argument would be a programming error, but a defensive nil guard is inexpensive.

address := buildStaticAddress(info.IP)
if err := ValidateBMCAddress(address); err != nil {
return "", err
}
return address, nil
}

if discoverer == nil {
return "", fmt.Errorf("discoverer is required for %s protocol", info.Protocol)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] error-handling-idiom

Nil-discoverer error uses fmt.Errorf without %w sentinel wrapping, unlike all other error paths in this package. Callers cannot use errors.Is to distinguish this failure.

}

systemPath, err := discoverer.DiscoverSystemPath(ctx, info.IP, bootMAC, username, password)
if err != nil {
return "", fmt.Errorf("redfish discovery failed for %s: %w", info.IP, err)
}

address := buildRedfishAddress(info.IP, info.Protocol, systemPath)
if err := ValidateBMCAddress(address); err != nil {
return "", err
}

return address, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
Copyright 2026.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package bmcdiscovery

import (
"testing"

. "github.com/onsi/ginkgo/v2" //nolint:revive,staticcheck
. "github.com/onsi/gomega" //nolint:revive,staticcheck
)

func TestBMCDiscovery(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "BMCDiscovery Suite")
}
Loading
Loading