mirror of https://github.com/adamveld12/dracli
commit
fe4555465a
@ -0,0 +1,3 @@
|
||||
.vscode
|
||||
credentials.json
|
||||
dracli
|
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Adam Veldhousen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
@ -0,0 +1,45 @@
|
||||
# DRACLI
|
||||
|
||||
A quick and dirty CLI/client library for the Integrated Dell Remote Access Controller v6.
|
||||
|
||||
## CLI Usage
|
||||
|
||||
I recommend that you setup a new user in the iDRAC admin specifically for this CLI tool. It will prevent you from getting locked out of the main account if you log in too many times.
|
||||
|
||||
|
||||
If you do happen to lock yourself out, you can SSH into the iDRAC with your root credentials and run `racadm racreset`
|
||||
|
||||
|
||||
```sh
|
||||
# start by using login - stores at ~/.dracli/credentials.json
|
||||
dracli login -u root -p calvin -h 10.0.0.5
|
||||
|
||||
# manage power state
|
||||
dracli power [on|off|nmi|graceful_shutdown|cold_reboot|warm_reboot]
|
||||
|
||||
# query info about your server
|
||||
dracli query pwState sysDesc fans
|
||||
|
||||
# query info about your server continuously
|
||||
dracli query -watch 1s pwState sysDesc fans
|
||||
|
||||
# list help
|
||||
dracli help
|
||||
|
||||
# log out removes credentials
|
||||
dracli logout
|
||||
```
|
||||
|
||||
|
||||
## Future Features
|
||||
|
||||
- xml output
|
||||
- manage/query multiple servers simultaneously
|
||||
- remote console
|
||||
- run virtual console locally (requires java)
|
||||
- manage user accounts
|
||||
|
||||
|
||||
## LICENSE
|
||||
|
||||
MIT
|
@ -0,0 +1,192 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
xj "github.com/basgys/goxml2json"
|
||||
)
|
||||
|
||||
var (
|
||||
PowerStatus = Attribute("pwState")
|
||||
SystemDescription = Attribute("sysDesc")
|
||||
SystemRevision = Attribute("sysRev")
|
||||
HostName = Attribute("hostName")
|
||||
OSName = Attribute("osName")
|
||||
OSVersion = Attribute("osVersion")
|
||||
ServiceTag = Attribute("svcTag")
|
||||
ExpServiceCode = Attribute("expSvcCode")
|
||||
BiosVersion = Attribute("biosVer")
|
||||
FirmwareVersion = Attribute("fwVersion")
|
||||
LCCFirmwareVersion = Attribute("LCCfwVersion")
|
||||
IPV4Enabled = Attribute("v4Enabled")
|
||||
IPV4Address = Attribute("v4IPAddr")
|
||||
IPV6Enabled = Attribute("v6Enabled")
|
||||
IPV6LinkLocal = Attribute("v6LinkLocal")
|
||||
IPV6Address = Attribute("v6Addr")
|
||||
IPV6SiteLocal = Attribute("v6SiteLocal")
|
||||
MacAddress = Attribute("macAddr")
|
||||
Batteries = Attribute("batteries")
|
||||
FanRedundancy = Attribute("fansRedundancy")
|
||||
Fans = Attribute("fans")
|
||||
Intrusion = Attribute("intrusion")
|
||||
PowerSupplyRedundancy = Attribute("psRedundancy")
|
||||
PowerSupplies = Attribute("powerSupplies")
|
||||
RMVRedundancy = Attribute("rmvsRedundancy")
|
||||
RemovableStorage = Attribute("removableStorage")
|
||||
Temperatures = Attribute("temperatures")
|
||||
Voltages = Attribute("voltages")
|
||||
KVMEnabled = Attribute("kvmEnabled")
|
||||
PowerBudgetData = Attribute("budgetpowerdata")
|
||||
EventLog = Attribute("eventLogEntries")
|
||||
BootOnce = Attribute("vmBootOnce")
|
||||
FirstBootDevice = Attribute("firstBootDevice")
|
||||
VFKLicense = Attribute("vfkLicense")
|
||||
User = Attribute("user")
|
||||
IDRACLog = Attribute("racLogEntries")
|
||||
|
||||
PowerOff = PowerState(0)
|
||||
PowerOn = PowerState(1)
|
||||
NonMaskingInterrupt = PowerState(2)
|
||||
GracefulShutdown = PowerState(3)
|
||||
ColdReboot = PowerState(4)
|
||||
WarmReboot = PowerState(5)
|
||||
NoOverride = BootDevice(0)
|
||||
PXE = BootDevice(1)
|
||||
HardDrive = BootDevice(2)
|
||||
BIOS = BootDevice(6)
|
||||
VirtualCD = BootDevice(8)
|
||||
LocalSD = BootDevice(16)
|
||||
LocalCD = BootDevice(5)
|
||||
)
|
||||
|
||||
type Attribute string
|
||||
type PowerState uint8
|
||||
type BootDevice uint8
|
||||
|
||||
type SensorType struct {
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
client *http.Client
|
||||
AuthToken string
|
||||
Host string
|
||||
}
|
||||
|
||||
func (c *Client) doHTTP(path, qs string, body io.Reader) (string, []*http.Cookie, error) {
|
||||
method := "GET"
|
||||
if strings.ContainsAny(qs, "set") {
|
||||
method = "POST"
|
||||
}
|
||||
|
||||
uri := fmt.Sprintf("https://%s/data/%s?%s", c.Host, path, qs)
|
||||
req, _ := http.NewRequest(method, uri, body)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "_appwebSessionId_",
|
||||
Value: c.AuthToken,
|
||||
})
|
||||
|
||||
res, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, errors.New("could not make request")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
jsonData, err := xj.Convert(res.Body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode >= 300 || res.StatusCode < 200 {
|
||||
return jsonData.String(), res.Cookies(), fmt.Errorf("got a non 200 status: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
return jsonData.String(), res.Cookies(), nil
|
||||
}
|
||||
|
||||
func (c *Client) SetPowerState(ps PowerState) (string, error) {
|
||||
res, _, err := c.doHTTP("", fmt.Sprintf("set=pwState:%d", ps), nil)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (c *Client) SetBootOverride(bo BootDevice, bootOnce bool) (string, error) {
|
||||
res, _, err := c.doHTTP("", fmt.Sprintf("set=vmBootOnce:%v,firstBootDevice:%d", bootOnce, bo), nil)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (c *Client) Query(ds ...Attribute) (string, error) {
|
||||
bufs := bytes.NewBufferString("")
|
||||
for idx, attr := range ds {
|
||||
bufs.WriteString(string(attr))
|
||||
if idx < len(ds)-1 {
|
||||
bufs.WriteString(",")
|
||||
}
|
||||
}
|
||||
|
||||
res, _, err := c.doHTTP("", fmt.Sprintf("get=%s", bufs.String()), nil)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func NewFromCredentials(path string) (*Client, error) {
|
||||
credential, err := LoadCredentials(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, errors.New("You should log in first")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c, err := NewClient(credential.Host, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.AuthToken = credential.AuthToken
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func NewClient(host string, skipVerify bool) (*Client, error) {
|
||||
c := &http.Client{
|
||||
Timeout: time.Second * 5,
|
||||
Transport: &http.Transport{
|
||||
IdleConnTimeout: time.Second,
|
||||
|
||||
TLSHandshakeTimeout: time.Second * 5,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: skipVerify,
|
||||
},
|
||||
},
|
||||
}
|
||||
client := &Client{client: c, Host: host}
|
||||
// err := client.Login(host, username, password)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) Login(username, password string) (string, error) {
|
||||
body := bytes.NewBufferString(fmt.Sprintf("user=%s&password=%s", username, password))
|
||||
_, cookies, err := c.doHTTP("login", "", body)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "_appwebSessionId_" {
|
||||
c.AuthToken = cookie.Value
|
||||
return cookie.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("could not find auth token in cookie")
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Credential struct {
|
||||
Host string
|
||||
AuthToken string
|
||||
}
|
||||
|
||||
func LoadCredentials(path string) (*Credential, error) {
|
||||
fp := fmt.Sprintf("%s/credentials.json", path)
|
||||
f, err := os.Open(fp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
credential := &Credential{}
|
||||
dec := json.NewDecoder(f)
|
||||
if err := dec.Decode(&credential); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return credential, nil
|
||||
}
|
||||
|
||||
func SaveCredentials(path string, c Credential) error {
|
||||
f, err := os.Create(fmt.Sprintf("%s/credentials.json", path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", "\t")
|
||||
if err := enc.Encode(c); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
module github.com/adamveld12/dracli
|
||||
|
||||
require (
|
||||
github.com/basgys/goxml2json v1.1.0
|
||||
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3 // indirect
|
||||
golang.org/x/text v0.3.0 // indirect
|
||||
)
|
@ -0,0 +1,6 @@
|
||||
github.com/basgys/goxml2json v1.1.0 h1:4ln5i4rseYfXNd86lGEB+Vi652IsIXIvggKM/BhUKVw=
|
||||
github.com/basgys/goxml2json v1.1.0/go.mod h1:wH7a5Np/Q4QoECFIU8zTQlZwZkrilY0itPfecMw41Dw=
|
||||
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3 h1:ulvT7fqt0yHWzpJwI57MezWnYDVpCAYBVuYst/L+fAY=
|
||||
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
@ -0,0 +1,276 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
commands = map[string]func(map[string][]string) error{
|
||||
"login": loginAction,
|
||||
"logout": logoutAction,
|
||||
"power": powerStateAction,
|
||||
"query": queryAction,
|
||||
"help": helpAction,
|
||||
}
|
||||
|
||||
queryHelp = []Attribute{
|
||||
PowerStatus,
|
||||
SystemDescription,
|
||||
SystemRevision,
|
||||
HostName,
|
||||
OSName,
|
||||
OSVersion,
|
||||
ServiceTag,
|
||||
ExpServiceCode,
|
||||
BiosVersion,
|
||||
FirmwareVersion,
|
||||
LCCFirmwareVersion,
|
||||
IPV4Enabled,
|
||||
IPV4Address,
|
||||
IPV6Enabled,
|
||||
IPV6LinkLocal,
|
||||
IPV6Address,
|
||||
IPV6SiteLocal,
|
||||
MacAddress,
|
||||
Batteries,
|
||||
FanRedundancy,
|
||||
Fans,
|
||||
Intrusion,
|
||||
PowerSupplyRedundancy,
|
||||
PowerSupplies,
|
||||
RMVRedundancy,
|
||||
RemovableStorage,
|
||||
Temperatures,
|
||||
Voltages,
|
||||
KVMEnabled,
|
||||
PowerBudgetData,
|
||||
EventLog,
|
||||
BootOnce,
|
||||
FirstBootDevice,
|
||||
VFKLicense,
|
||||
User,
|
||||
IDRACLog,
|
||||
}
|
||||
)
|
||||
|
||||
func main() {
|
||||
c, err := ToCommand(os.Args[1:]...)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
action, ok := commands[c.Name]
|
||||
if !ok {
|
||||
fmt.Printf("The command \"%s\" was not found.\n", c.Name)
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
if err := action(c.Arguments); err != nil {
|
||||
fmt.Printf("The command exited with an error:\n%v\n", err)
|
||||
os.Exit(-1)
|
||||
}
|
||||
}
|
||||
|
||||
func powerStateAction(args map[string][]string) error {
|
||||
c, err := NewFromCredentials(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawPs, ok := args[""]
|
||||
if !ok || len(rawPs) == 0 {
|
||||
return errors.New("specify a power state (on|off|cold_reboot|warm_reboot)")
|
||||
}
|
||||
|
||||
var ps PowerState
|
||||
switch rawPs[0] {
|
||||
case "on":
|
||||
ps = PowerOn
|
||||
case "off":
|
||||
ps = PowerOff
|
||||
case "cold_reboot":
|
||||
ps = ColdReboot
|
||||
case "warm_reboot":
|
||||
ps = WarmReboot
|
||||
case "nmi":
|
||||
ps = NonMaskingInterrupt
|
||||
case "graceful_shutdown":
|
||||
ps = GracefulShutdown
|
||||
default:
|
||||
return errors.New("specify a power state (on|off|cold_reboot|warm_reboot)")
|
||||
}
|
||||
|
||||
res, err := c.SetPowerState(ps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%s\n", res)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func queryAction(args map[string][]string) error {
|
||||
c, err := NewFromCredentials(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
queryParams, ok := args[""]
|
||||
if !ok {
|
||||
return errors.New("you should pass query parameters")
|
||||
}
|
||||
|
||||
qps := []Attribute{}
|
||||
for _, qp := range queryParams {
|
||||
qps = append(qps, Attribute(qp))
|
||||
}
|
||||
|
||||
output, err := c.Query(qps...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%s", output)
|
||||
|
||||
watch, watchOk := args["watch"]
|
||||
if watchOk && len(watch) > 0 {
|
||||
duration, err := time.ParseDuration(watch[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-time.After(duration):
|
||||
output, err := c.Query(qps...)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%s", output)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
notifChan := make(chan os.Signal)
|
||||
signal.Notify(notifChan, os.Interrupt)
|
||||
<-notifChan
|
||||
cancel()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loginAction(args map[string][]string) error {
|
||||
u, ok1 := args["u"]
|
||||
p, ok2 := args["p"]
|
||||
h, ok3 := args["h"]
|
||||
|
||||
if !ok1 || !ok2 || !ok3 {
|
||||
return errors.New("username (-u), password (-p), and a host (-h) must be defined")
|
||||
}
|
||||
|
||||
username, password, host := u[0], p[0], h[0]
|
||||
|
||||
credential, err := LoadCredentials(".")
|
||||
if (err == nil || os.IsExist(err)) && credential.Host == host {
|
||||
return errors.New("you are already logged in")
|
||||
}
|
||||
|
||||
fmt.Printf("logging in to %s:%s@%s\n", username, password, host)
|
||||
client, err := NewClient(host, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authToken, err := client.Login(username, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := SaveCredentials(".", Credential{
|
||||
Host: host,
|
||||
AuthToken: authToken,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func logoutAction(args map[string][]string) error {
|
||||
_, err := LoadCredentials(".")
|
||||
if err == nil && os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.Remove("./credentials.json"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func helpAction(args map[string][]string) error {
|
||||
fmt.Println("login -u [username] -p [password] -h [host]: logs you in")
|
||||
fmt.Println("logout: logs you out")
|
||||
fmt.Println("power [on|off|nmi|graceful_shutdown|cold_reboot|warm_reboot]: manage power state of the server")
|
||||
fmt.Printf("query [-watch 1[s|m|h]] <attribute>,<attribute2>...: gets info about the server's various sensors and attributes\nPossible attributes:")
|
||||
for idx, q := range queryHelp {
|
||||
if idx%10 == 0 {
|
||||
fmt.Println()
|
||||
}
|
||||
fmt.Printf("%s ", q)
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
type Command struct {
|
||||
Name string
|
||||
Arguments map[string][]string
|
||||
}
|
||||
|
||||
func ToCommand(args ...string) (Command, error) {
|
||||
if len(args) < 1 {
|
||||
return Command{}, nil
|
||||
|
||||
}
|
||||
c := Command{
|
||||
Name: args[0],
|
||||
Arguments: map[string][]string{},
|
||||
}
|
||||
|
||||
for i := 1; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg[0] == '-' {
|
||||
argumentV := "true"
|
||||
if len(args) > i+1 && arg[1:] != "once" {
|
||||
argumentV = args[i+1]
|
||||
i++
|
||||
}
|
||||
|
||||
c.Arguments[arg[1:]] = []string{argumentV}
|
||||
} else {
|
||||
v, ok := c.Arguments[""]
|
||||
if ok {
|
||||
v = append(v, arg)
|
||||
c.Arguments[""] = v
|
||||
} else {
|
||||
c.Arguments[""] = []string{arg}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_ToCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
want Command
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "login works",
|
||||
args: "login -u root -p calvin 10.0.0.5",
|
||||
want: Command{
|
||||
Name: "login",
|
||||
Arguments: map[string][]string{
|
||||
"u": []string{"root"},
|
||||
"p": []string{"calvin"},
|
||||
"": []string{"10.0.0.5"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "boot settings",
|
||||
args: "boot_settings -once local_cd",
|
||||
want: Command{
|
||||
Name: "boot_settings",
|
||||
Arguments: map[string][]string{
|
||||
"once": []string{"true"},
|
||||
"": []string{"local_cd"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "query multi value",
|
||||
args: "query pwState kvmEnabled voltages temperatures",
|
||||
want: Command{
|
||||
Name: "query",
|
||||
Arguments: map[string][]string{
|
||||
"": []string{"pwState", "kvmEnabled", "voltages", "temperatures"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
args := strings.Split(tt.args, " ")
|
||||
got, err := ToCommand(args...)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ToCommand() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("ToCommand() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in new issue