engine: resources: Add a bmc resource

This resource manages bmc devices in servers or elsewhere. This also
integrates with the provisioner code.
This commit is contained in:
James Shubin
2025-01-03 18:19:31 -05:00
parent 3107dfbd08
commit e6d614f4dd
13 changed files with 1017 additions and 5 deletions

View File

@@ -0,0 +1,466 @@
// Mgmt
// Copyright (C) 2013-2024+ James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.
package resources
import (
"context"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"github.com/purpleidea/mgmt/engine"
"github.com/purpleidea/mgmt/engine/traits"
bmclib "github.com/bmc-toolbox/bmclib/v2"
"github.com/bmc-toolbox/bmclib/v2/providers/rpc"
)
func init() {
engine.RegisterResource("bmc:power", func() engine.Res { return &BmcPowerRes{} })
}
const (
// DefaultBmcPowerPort is the default port we try to connect on.
DefaultBmcPowerPort = 443
// BmcDriverSecureSuffix is the magic char we append to a driver name to
// specify we want the SSL/TLS variant.
BmcDriverSecureSuffix = "s"
// BmcDriverRPC is the RPC driver.
BmcDriverRPC = "rpc"
// BmcDriverGofish is the gofish driver.
BmcDriverGofish = "gofish"
)
// BmcPowerRes is a resource that manages power state of a BMC. This is usually
// used for turning computers on and off. The name value can be a big URL string
// in the form: `driver://user:pass@hostname:port` for example you may see:
// gofishs://ADMIN:hunter2@127.0.0.1:8800 to use the "https" variant of the
// gofish driver.
//
// NOTE: New drivers should either not end in "s" or at least not be identical
// to the name of another driver an "s" is added or removed to the end.
type BmcPowerRes struct {
traits.Base // add the base methods without re-implementation
init *engine.Init
// Hostname to connect to. If not specified, we parse this from the
// Name.
Hostname string `lang:"hostname" yaml:"hostname"`
// Port to connect to. If not specified, we parse this from the Name.
Port int `lang:"port" yaml:"port"`
// Username to use to connect. If not specified, we parse this from the
// Name.
// TODO: If the Username field is not set, should we parse from the
// Name? It's not really part of the BMC unique identifier so maybe we
// shouldn't use that.
Username string `lang:"username" yaml:"username"`
// Password to use to connect. We do NOT parse this from the Name unless
// you set InsecurePassword to true.
// XXX: Use mgmt magic credentials in the future.
Password string `lang:"password" yaml:"password"`
// InsecurePassword can be set to true to allow a password in the Name.
InsecurePassword bool `lang:"insecure_password" yaml:"insecure_password"`
// Driver to use, such as: "gofish" or "rpc". This is a different
// concept than the "bmclib" driver vs provider distinction. Here we
// just statically pick what we're using without any magic. If not
// specified, we parse this from the Name scheme. If this ends with an
// extra "s" then we use https instead of http.
Driver string `lang:"driver" yaml:"driver"`
// State of machine power. Can be "on" or "off".
State string `lang:"state" yaml:"state"`
driver string
scheme string
}
// validDriver determines if we are using a valid drive. This does not include
// the magic "s" bits. This function need to be expanded as we support more
// drivers.
func (obj *BmcPowerRes) validDriver(driver string) error {
if driver == BmcDriverRPC {
return nil
}
if driver == BmcDriverGofish {
return nil
}
return fmt.Errorf("unknown driver: %s", driver)
}
// getHostname returns the hostname that we want to connect to. If the Hostname
// field is set, we use that, otherwise we parse from the Name.
func (obj *BmcPowerRes) getHostname() string {
if obj.Hostname != "" {
return obj.Hostname
}
u, err := url.Parse(obj.Name())
if err != nil || u == nil {
return ""
}
// SplitHostPort splits a network address of the form "host:port",
// "host%zone:port", "[host]:port" or "[host%zone]:port" into host or
// host%zone and port.
host, port, err := net.SplitHostPort(u.Host)
if err != nil {
return u.Host // must be a naked hostname or ip w/o port
}
_ = port
return host
}
// getPort returns the port that we want to connect to. If the Port field is
// set, we use that, otherwise we parse from the Name.
//
// NOTE: We return a string since all the bmclib things usually expect a string,
// but if that gets fixed we should return an int here instead.
func (obj *BmcPowerRes) getPort() string {
if obj.Port != 0 {
return strconv.Itoa(obj.Port)
}
u, err := url.Parse(obj.Name())
if err != nil || u == nil {
return ""
}
// SplitHostPort splits a network address of the form "host:port",
// "host%zone:port", "[host]:port" or "[host%zone]:port" into host or
// host%zone and port.
host, port, err := net.SplitHostPort(u.Host)
if err != nil {
return strconv.Itoa(DefaultBmcPowerPort) // default port
}
_ = host
return port
}
// getUsername returns the username that we want to connect with. If the
// Username field is set, we use that, otherwise we parse from the Name.
// TODO: If the Username field is not set, should we parse from the Name? It's
// not really part of the BMC unique identifier so maybe we shouldn't use that.
func (obj *BmcPowerRes) getUsername() string {
if obj.Username != "" {
return obj.Username
}
u, err := url.Parse(obj.Name())
if err != nil || u == nil || u.User == nil {
return ""
}
return u.User.Username()
}
// getPassword returns the password that we want to connect with.
// XXX: Use mgmt magic credentials in the future.
func (obj *BmcPowerRes) getPassword() string {
if obj.Password != "" || !obj.InsecurePassword {
return obj.Password
}
// NOTE: We don't look at any password string from the name unless the
// InsecurePassword field is true.
u, err := url.Parse(obj.Name())
if err != nil || u == nil || u.User == nil {
return ""
}
password, ok := u.User.Password()
if !ok {
return ""
}
return password
}
// getRawDriver returns the raw magic driver string. If the Driver field is set,
// we use that, otherwise we parse from the Name. This version may include the
// magic "s" at the end.
func (obj *BmcPowerRes) getRawDriver() string {
if obj.Driver != "" {
return obj.Driver
}
u, err := url.Parse(obj.Name())
if err != nil || u == nil {
return ""
}
return u.Scheme
}
// getDriverAndScheme figures out which driver and scheme we want to use.
func (obj *BmcPowerRes) getDriverAndScheme() (string, string, error) {
driver := obj.getRawDriver()
err := obj.validDriver(driver)
if err == nil {
return driver, "http", nil
}
driver = strings.TrimSuffix(driver, BmcDriverSecureSuffix)
if err := obj.validDriver(driver); err == nil {
return driver, "https", nil
}
return "", "", err // return the first error
}
// getDriver returns the actual driver that we want to connect with. If the
// Driver field is set, we use that, otherwise we parse from the Name. This
// version does NOT include the magic "s" at the end.
func (obj *BmcPowerRes) getDriver() string {
return obj.driver
}
// getScheme figures out which scheme we want to use.
func (obj *BmcPowerRes) getScheme() string {
return obj.scheme
}
// Default returns some sensible defaults for this resource.
func (obj *BmcPowerRes) Default() engine.Res {
return &BmcPowerRes{}
}
// Validate if the params passed in are valid data.
func (obj *BmcPowerRes) Validate() error {
// XXX: Force polling until we have real events...
if obj.MetaParams().Poll == 0 {
return fmt.Errorf("events are not yet supported, use polling")
}
if obj.getHostname() == "" {
return fmt.Errorf("need a Hostname")
}
//if obj.getUsername() == "" {
// return fmt.Errorf("need a Username")
//}
if obj.getRawDriver() == "" {
return fmt.Errorf("need a Driver")
}
if _, _, err := obj.getDriverAndScheme(); err != nil {
return err
}
return nil
}
// Init runs some startup code for this resource.
func (obj *BmcPowerRes) Init(init *engine.Init) error {
obj.init = init // save for later
driver, scheme, err := obj.getDriverAndScheme()
if err != nil {
// programming error (we checked in Validate)
return err
}
obj.driver = driver
obj.scheme = scheme
return nil
}
// Cleanup is run by the engine to clean up after the resource is done.
func (obj *BmcPowerRes) Cleanup() error {
return nil
}
// client builds the bmclib client. The API to build it is complicated.
func (obj *BmcPowerRes) client() *bmclib.Client {
// NOTE: The bmclib API is weird, you can't put the port in this string!
u := fmt.Sprintf("%s://%s", obj.getScheme(), obj.getHostname())
uPort := u
if p := obj.getPort(); p != "" {
uPort = u + ":" + p
}
opts := []bmclib.Option{}
if obj.getDriver() == BmcDriverRPC {
opts = append(opts, bmclib.WithRPCOpt(rpc.Provider{
// NOTE: The main API cannot take a port, but here we do!
ConsumerURL: uPort,
}))
}
if p := obj.getPort(); p != "" {
switch obj.getDriver() {
case BmcDriverRPC:
// TODO: ???
case BmcDriverGofish:
// XXX: Why doesn't this accept an int?
opts = append(opts, bmclib.WithRedfishPort(p))
//case BmcDriverOpenbmc:
// // XXX: Why doesn't this accept an int?
// opts = append(opts, openbmc.WithPort(p))
default:
// TODO: error or pass through?
obj.init.Logf("unhandled driver: %s", obj.getDriver())
}
}
client := bmclib.NewClient(u, obj.getUsername(), obj.Password, opts...)
if obj.getDriver() != "" && obj.getDriver() != BmcDriverRPC {
client = client.For(obj.getDriver()) // limit to this provider
}
return client
}
// Watch is the primary listener for this resource and it outputs events.
func (obj *BmcPowerRes) Watch(ctx context.Context) error {
obj.init.Running() // when started, notify engine that we're running
select {
case <-ctx.Done(): // closed by the engine to signal shutdown
}
//obj.init.Event() // notify engine of an event (this can block)
return nil
}
// CheckApply method for BmcPower resource. Does nothing, returns happy!
func (obj *BmcPowerRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
client := obj.client()
if err := client.Open(ctx); err != nil {
return false, err
}
defer client.Close(ctx) // (err error)
if obj.init.Debug {
obj.init.Logf("connected ok")
}
state, err := client.GetPowerState(ctx)
if err != nil {
return false, err
}
state = strings.ToLower(state) // normalize
obj.init.Logf("get state: %s", state)
if !apply {
return false, nil
}
if obj.State == state {
return true, nil
}
// TODO: should this be "On" and "Off"? Does case matter?
ok, err := client.SetPowerState(ctx, obj.State)
if err != nil {
return false, err
}
if !ok {
// TODO: When is this ever false?
}
obj.init.Logf("set state: %s", obj.State)
return false, nil
}
// Cmp compares two resources and returns an error if they are not equivalent.
func (obj *BmcPowerRes) Cmp(r engine.Res) error {
// we can only compare BmcPowerRes to others of the same resource kind
res, ok := r.(*BmcPowerRes)
if !ok {
return fmt.Errorf("not a %s", obj.Kind())
}
if obj.Hostname != res.Hostname {
return fmt.Errorf("the Hostname differs")
}
if obj.Port != res.Port {
return fmt.Errorf("the Port differs")
}
if obj.Username != res.Username {
return fmt.Errorf("the Username differs")
}
if obj.Password != res.Password {
return fmt.Errorf("the Password differs")
}
if obj.InsecurePassword != res.InsecurePassword {
return fmt.Errorf("the InsecurePassword differs")
}
if obj.Driver != res.Driver {
return fmt.Errorf("the Driver differs")
}
if obj.State != res.State {
return fmt.Errorf("the State differs")
}
return nil
}
// UnmarshalYAML is the custom unmarshal handler for this struct. It is
// primarily useful for setting the defaults.
func (obj *BmcPowerRes) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawRes BmcPowerRes // indirection to avoid infinite recursion
def := obj.Default() // get the default
res, ok := def.(*BmcPowerRes) // put in the right format
if !ok {
return fmt.Errorf("could not convert to BmcPowerRes")
}
raw := rawRes(*res) // convert; the defaults go here
if err := unmarshal(&raw); err != nil {
return err
}
*obj = BmcPowerRes(raw) // restore from indirection with type conversion!
return nil
}