diff --git a/README.md b/README.md index 9f256f7..85300da 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,9 @@ PXE Pilot needs to know three things: All those information are described in the YAML file `/etc/pxe-pilot/pxe-pilot.yml`. +Optionnaly, IPMI MAC address (or IP address) and credentials can be specified. When IPMI is available, +PXE Pilot client shows power state for each host. + __Example:__ ```yaml @@ -56,8 +59,13 @@ hosts: mac_addresses: ["00:00:00:00:00:01"] - name: h2 mac_addresses: ["00:00:00:00:00:02"] + ipmi: + mac_address: "00:00:00:00:00:a2" + username: "user" + password: "pass" + interface: "lanplus" - name: h3 - mac_addresses: ["00:00:00:00:00:03", "00:00:00:00:00:33", "00:00:00:00:01:33"] + mac_addresses: ["00:00:00:00:00:03", "00:00:00:00:00:33"] tftp: root: "/var/tftp" @@ -120,13 +128,13 @@ $ pxe-pilot config list ``` $ pxe-pilot host list -+------+---------------+-----------------------------------------------------------+ -| NAME | CONFIGURATION | MAC ADDRESSES | -+------+---------------+-----------------------------------------------------------+ -| h1 | local | 00:00:00:00:00:01 | -| h2 | | 00:00:00:00:00:02 | -| h3 | local | 00:00:00:00:00:03 | 00:00:00:00:00:33 | 00:00:00:00:01:33 | -+------+---------------+-----------------------------------------------------------+ ++------+---------------+---------------------------------------+-------------------+-----------+-------------+ +| NAME | CONFIGURATION | MAC ADDRESSES | IPMI MAC | IPMI HOST | POWER STATE | ++------+---------------+---------------------------------------+-------------------+-----------+-------------+ +| h1 | local | 00:00:00:00:00:01 | | | | +| h2 | | 00:00:00:00:00:02 | 00:00:00:00:00:a2 | 1.2.3.4 | On | +| h3 | local | 00:00:00:00:00:03 | 00:00:00:00:00:33 |     | | | ++------+---------------+---------------------------------------+-------------------+-----------+-------------+ ``` ### Deploy configuration for host(s) diff --git a/api/api.go b/api/api.go index 6815dbf..3a4ad92 100644 --- a/api/api.go +++ b/api/api.go @@ -3,6 +3,8 @@ package api import ( "fmt" "io/ioutil" + "net/http" + "time" "github.com/ggiamarchi/pxe-pilot/logger" "github.com/ggiamarchi/pxe-pilot/model" @@ -14,7 +16,14 @@ import ( func Run(appConfigFile string) { logger.Info("Starting PXE Pilot server...") appConfig := loadAppConfig(appConfigFile) - api(appConfig).Run(fmt.Sprintf(":%d", appConfig.Server.Port)) + + s := &http.Server{ + Addr: fmt.Sprintf(":%d", appConfig.Server.Port), + Handler: api(appConfig), + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + s.ListenAndServe() } func loadAppConfig(file string) *model.AppConfig { @@ -44,6 +53,7 @@ func api(appConfig *model.AppConfig) *gin.Engine { deployConfiguration(api, appConfig) readHosts(api, appConfig) + rebootHost(api, appConfig) return api } diff --git a/api/hosts.go b/api/hosts.go index 76733b5..eed8a0b 100644 --- a/api/hosts.go +++ b/api/hosts.go @@ -10,6 +10,21 @@ func readHosts(api *gin.Engine, appConfig *model.AppConfig) { api.GET("/hosts", func(c *gin.Context) { hosts := service.ReadHosts(appConfig) c.JSON(200, hosts) - c.Writer.WriteHeader(200) + }) +} + +func rebootHost(api *gin.Engine, appConfig *model.AppConfig) { + api.PATCH("/hosts/:name/reboot", func(c *gin.Context) { + for _, host := range appConfig.Hosts { + if host.Name == c.Param("name") { + if service.RebootHost(host) != nil { + c.Writer.WriteHeader(409) + return + } + c.Writer.WriteHeader(204) + return + } + } + c.Writer.WriteHeader(404) }) } diff --git a/cli.go b/cli.go index e3bf4dc..9427e2c 100644 --- a/cli.go +++ b/cli.go @@ -105,6 +105,11 @@ func setupCLI() { logger.Init(!*debug) var hosts = &[]*model.Host{} statusCode, err := http.Request("GET", *serverURL, "/hosts", nil, hosts) + + if err != nil { + os.Stdout.WriteString("Error : " + err.Error()) + } + if err != nil || statusCode != 200 { os.Stdout.WriteString("Error...") cli.Exit(1) @@ -112,7 +117,7 @@ func setupCLI() { // Print data table table := tablewriter.NewWriter(os.Stdout) - table.SetHeader([]string{"Name", "Configuration", "MAC Addresses"}) + table.SetHeader([]string{"Name", "Configuration", "MAC", "IPMI MAC", "IPMI HOST", "Power State"}) table.SetAutoWrapText(false) for _, h := range *hosts { @@ -121,6 +126,13 @@ func setupCLI() { configuration = h.Configuration.Name } + var ipmi *model.IPMI + if h.IPMI != nil { + ipmi = h.IPMI + } else { + ipmi = &model.IPMI{} + } + var macAddresses bytes.Buffer for i := 0; i < len(h.MACAddresses); i++ { @@ -130,11 +142,40 @@ func setupCLI() { macAddresses.WriteString(h.MACAddresses[i]) } - table.Append([]string{h.Name, configuration, macAddresses.String()}) + table.Append([]string{h.Name, configuration, macAddresses.String(), ipmi.MACAddress, ipmi.Hostname, ipmi.Status}) } table.Render() } }) + cmd.Command("reboot", "(re)boot a host", func(cmd *cli.Cmd) { + cmd.Spec = "HOSTNAME" + + var ( + hostname = cmd.StringArg("HOSTNAME", "", "Host to reboot or reboot if powered off") + ) + + cmd.Action = func() { + + logger.Init(!*debug) + + statusCode, err := http.Request("PATCH", *serverURL, "/hosts/"+*hostname+"/reboot", nil, nil) + + // Print data table + table := tablewriter.NewWriter(os.Stdout) + table.SetAutoWrapText(false) + table.SetHeader([]string{"Name", "Reboot"}) + + if err != nil || statusCode != 204 { + table.Append([]string{*hostname, "ERROR"}) + table.Render() + cli.Exit(1) + } else { + table.Append([]string{*hostname, "OK"}) + table.Render() + } + } + + }) }) app.Run(os.Args) diff --git a/common/http/http.go b/common/http/http.go index f64b9ec..22f93d3 100644 --- a/common/http/http.go +++ b/common/http/http.go @@ -31,14 +31,14 @@ func Request(method string, baseURL string, path string, data interface{}, respo var transport = &http.Transport{ Dial: (&net.Dialer{ - Timeout: 5 * time.Second, + Timeout: 10 * time.Second, }).Dial, - TLSHandshakeTimeout: 5 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, } client := http.Client{ Transport: transport, - Timeout: time.Duration(5 * time.Second), + Timeout: time.Duration(10 * time.Second), } resp, err := client.Do(req) diff --git a/model/appconfig.go b/model/appconfig.go index 9ccfad4..32606c2 100644 --- a/model/appconfig.go +++ b/model/appconfig.go @@ -5,7 +5,7 @@ import ( ) type AppConfig struct { - Hosts []Host + Hosts []*Host Tftp struct { Root string } diff --git a/model/host.go b/model/host.go index a7c062f..8e7a46d 100644 --- a/model/host.go +++ b/model/host.go @@ -5,7 +5,8 @@ import "fmt" type Host struct { Name string `json:"name" yaml:"name"` MACAddresses []string `json:"macAddresses" yaml:"mac_addresses"` - Configuration *Configuration `json:"configuration"` + Configuration *Configuration `json:"configuration" yaml:"configuration"` + IPMI *IPMI `json:"ipmi" yaml:"ipmi"` } func (h *Host) String() string { diff --git a/model/ipmi.go b/model/ipmi.go new file mode 100644 index 0000000..5dfc891 --- /dev/null +++ b/model/ipmi.go @@ -0,0 +1,16 @@ +package model + +import "fmt" + +type IPMI struct { + MACAddress string `json:"macAddress" yaml:"mac_address"` + Username string `json:"username" yaml:"username"` + Password string `json:"password" yaml:"password"` + Interface string `json:"interface" yaml:"interface"` + Status string `json:"status" yaml:"status"` + Hostname string `json:"hostname" yaml:"hostname"` +} + +func (i *IPMI) String() string { + return fmt.Sprintf("%+v", *i) +} diff --git a/service/ipmi.go b/service/ipmi.go new file mode 100644 index 0000000..291fa0e --- /dev/null +++ b/service/ipmi.go @@ -0,0 +1,155 @@ +package service + +import ( + "bytes" + "fmt" + "os/exec" + "strings" + + "github.com/ggiamarchi/pxe-pilot/model" + + "github.com/ggiamarchi/pxe-pilot/logger" +) + +// ChassisPowerStatus is a wrapper for for `ipmitool chassis power status` +func ChassisPowerStatus(context *model.IPMI) (string, error) { + stdout, _, err := ipmitool(context, "chassis power status") + if err != nil { + context.Status = "Unknown" + return context.Status, err + } + if strings.Contains(*stdout, "Chassis Power is on") { + context.Status = "On" + return context.Status, nil + } + context.Status = "Off" + return context.Status, nil +} + +// ChassisPowerOn is a wrapper for for `ipmitool chassis power on` +func ChassisPowerOn(context *model.IPMI) error { + _, _, err := ipmitool(context, "chassis power on") + return err +} + +// ChassisPowerReset is a wrapper for for `ipmitool chassis power reset` +func ChassisPowerReset(context *model.IPMI) error { + _, _, err := ipmitool(context, "chassis power reset") + return err +} + +// ChassisPowerOff is a wrapper for for `ipmitool chassis power off` +func ChassisPowerOff(context *model.IPMI) error { + _, _, err := ipmitool(context, "chassis power off") + return err +} + +func execCommand(command string, args ...interface{}) (string, string, error) { + + fmtCommand := fmt.Sprintf(command, args...) + + splitCommand := strings.Split(fmtCommand, " ") + + logger.Info("Executing command :: %s :: with args :: %v => %s", command, args, fmtCommand) + + cmdName := splitCommand[0] + cmdArgs := splitCommand[1:len(splitCommand)] + + cmd := exec.Command(cmdName, cmdArgs...) + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + err := cmd.Run() + + return stdout.String(), stderr.String(), err +} + +// getIPFromMAC reads the ARP table to find the IP address matching the given MAC address +func getIPFromMAC(mac string) (string, error) { + + stdout, _, err := execCommand("sudo arp -an") + + if err != nil { + return "", err + } + + lines := strings.Split(stdout, "\n") + + for _, v := range lines { + if strings.TrimSpace(v) == "" { + continue + } + fields := strings.Fields(v) + + if normalizeMACAddress(mac) == normalizeMACAddress(fields[3]) { + return fields[1][1 : len(fields[1])-1], nil + } + } + + return "", nil +} + +// normalizeMACAddress takes the input MAC address and remove every non hexa symbol +// and lowercase everything else +func normalizeMACAddress(mac string) string { + var buffer bytes.Buffer + + macArray := strings.Split(strings.ToLower(mac), ":") + + for i := 0; i < len(macArray); i++ { + m := macArray[i] + if len(m) == 1 { + buffer.WriteByte(byte('0')) + } + for j := 0; j < len(m); j++ { + if isHexChar(m[j]) { + buffer.WriteByte(m[j]) + } + } + } + return buffer.String() +} + +func isHexChar(char byte) bool { + switch char { + case + byte('a'), byte('b'), byte('c'), byte('d'), + byte('e'), byte('f'), byte('0'), byte('1'), + byte('2'), byte('3'), byte('4'), byte('5'), + byte('6'), byte('7'), byte('8'), byte('9'): + return true + } + return false +} + +func ipmitool(context *model.IPMI, command string) (*string, *string, error) { + + // Populate IPMI Hostname + if context.Hostname == "" { + context.Hostname, _ = getIPFromMAC(context.MACAddress) + } + + if context.Hostname == "" { + return nil, nil, logger.Errorf("Unable to find IPMI interface for MAC '%s'", context.MACAddress) + } + + var interfaceOpt string + if context.Interface != "" { + interfaceOpt = fmt.Sprintf(" -I %s", context.Interface) + } + + baseCmd := fmt.Sprintf("ipmitool%s -N 1 -R 2 -H %s -U %s -P %s ", interfaceOpt, context.Hostname, context.Username, context.Password) + + fullCommand := baseCmd + command + stdout, stderr, err := execCommand(fullCommand) + + if err != nil { + logger.Error("IPMI command failed <%s> - %s", fullCommand, err) + } + + return &stdout, &stderr, err +} diff --git a/service/service.go b/service/service.go index b91ed8b..a35c985 100644 --- a/service/service.go +++ b/service/service.go @@ -4,6 +4,7 @@ import ( "io/ioutil" "os" "strings" + "sync" "fmt" @@ -23,6 +24,18 @@ func ReadConfigurations(appConfig *model.AppConfig) []*model.Configuration { return configurations } +func RebootHost(host *model.Host) error { + switch status, err := ChassisPowerStatus(host.IPMI); status { + case "On": + return ChassisPowerReset(host.IPMI) + case "Off": + return ChassisPowerOn(host.IPMI) + case "Unknown": + return err + } + return logger.Errorf("Reboot host '%s' : Unknown error", host.Name) +} + func ReadHosts(appConfig *model.AppConfig) []*model.Host { pxelinuxDir := appConfig.Tftp.Root + "/pxelinux.cfg" @@ -36,10 +49,23 @@ func ReadHosts(appConfig *model.AppConfig) []*model.Host { hosts := make([]*model.Host, len(appConfig.Hosts)) + var wg sync.WaitGroup + for i, host := range appConfig.Hosts { + + if host.IPMI != nil { + wg.Add(1) + hostlocal := host + go func() { + defer wg.Done() + ChassisPowerStatus(hostlocal.IPMI) + }() + } + hosts[i] = &model.Host{ Name: host.Name, MACAddresses: host.MACAddresses, + IPMI: host.IPMI, } pxeFile := utils.PXEFilenameFromMAC(hosts[i].MACAddresses[0]) pxeFilePath := fmt.Sprintf("%s/%s", pxelinuxDir, pxeFile) @@ -58,6 +84,8 @@ func ReadHosts(appConfig *model.AppConfig) []*model.Host { } } + wg.Wait() + return hosts } @@ -96,7 +124,6 @@ func DeployConfiguration(appConfig *model.AppConfig, name string, hosts []*model if !configExists { return newPXEError("NOT_FOUND", "Configuration '%s' does not exists", name) } - // configFile := fmt.Sprintf("%s/%s", appConfig.Configuration.Directory, name) // Build maps in oder to optimize further searches hostsByName := make(map[string]*model.Host) @@ -113,7 +140,7 @@ func DeployConfiguration(appConfig *model.AppConfig, name string, hosts []*model hostsToDeploy := make(map[string]*model.Host) - // 2. Iterate over `hosts` + // Iterate over `hosts` for _, qh := range hosts { qh.MACAddress = strings.ToLower(qh.MACAddress) logger.Info("Processing :: %+v", qh) diff --git a/test/pxepilot.yml b/test/pxepilot.yml index 463c726..c38c890 100644 --- a/test/pxepilot.yml +++ b/test/pxepilot.yml @@ -3,6 +3,11 @@ hosts: - name: h1 mac_addresses: ["00:00:00:00:00:01"] + ipmi: + mac_address: "00:00:00:00:00:a1" + username: "user" + password: "password" + interface: "lanplus" - name: h2 mac_addresses: ["00:00:00:00:00:02"] - name: h3