Skip to content

Commit

Permalink
Merge pull request #12 from ggiamarchi/ipmi-support
Browse files Browse the repository at this point in the history
First step for IPMI Support
  • Loading branch information
ggiamarchi authored Feb 9, 2018
2 parents 6cc99ff + ca6d85b commit 73b2236
Show file tree
Hide file tree
Showing 11 changed files with 297 additions and 19 deletions.
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -44,6 +53,7 @@ func api(appConfig *model.AppConfig) *gin.Engine {
deployConfiguration(api, appConfig)

readHosts(api, appConfig)
rebootHost(api, appConfig)

return api
}
17 changes: 16 additions & 1 deletion api/hosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
45 changes: 43 additions & 2 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,19 @@ 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)
}

// 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 {
Expand All @@ -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++ {
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions common/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion model/appconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
)

type AppConfig struct {
Hosts []Host
Hosts []*Host
Tftp struct {
Root string
}
Expand Down
3 changes: 2 additions & 1 deletion model/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions model/ipmi.go
Original file line number Diff line number Diff line change
@@ -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)
}
155 changes: 155 additions & 0 deletions service/ipmi.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 73b2236

Please sign in to comment.