Skip to content

Commit 2636c86

Browse files
committed
feat: Add boot subcmd
1 parent 83ebbd1 commit 2636c86

19 files changed

Lines changed: 799 additions & 65 deletions

.golangci.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
version: "2"
2+
linters:
3+
default: all
4+
disable:
5+
- depguard
6+
- err113
7+
- exhaustruct
8+
- testpackage
9+
- paralleltest
10+
- wrapcheck
11+
settings:
12+
errcheck:
13+
check-type-assertions: true
14+
check-blank: true
15+
gocyclo:
16+
min-complexity: 15
17+
goconst:
18+
min-len: 3
19+
min-occurrences: 3
20+
lll:
21+
line-length: 120
22+
misspell:
23+
locale: US
24+
revive:
25+
severity: warning
26+
rules:
27+
- name: exported
28+
- name: package-comments
29+
- name: var-naming
30+
varnamelen:
31+
ignore-decls:
32+
- t testing.T
33+
- T any
34+
- ok bool
35+
- w http.ResponseWriter
36+
- r *http.Request

cmd/bmctl/boot.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package main
2+
3+
import (
4+
"github.com/GSI-HPC/bmctl/pkg/bmc"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
func newBootCmd(bmcClientConfig *bmc.ClientConfig) *cobra.Command {
9+
cmd := cobra.Command{
10+
Use: "boot IMAGE",
11+
Short: "Boot an image file",
12+
Long: `Out-of-band initiated device boot
13+
14+
1. Create SSH tunnel, if SSH proxy is set (expects OpenSSH ssh command in $PATH)
15+
2. Connect to and authenticate with BMC
16+
3. Start HTTPS server serving given image file
17+
4. Insert image URL as virtual medium
18+
5. Set next boot target to this virtual medium
19+
6. Reboot the device
20+
7. Wait until Ctrl+C, after which the HTTPS server is shut down
21+
22+
Limitations (currently):
23+
* Works for the first device per BMC only
24+
* local SSH Proxy Port hardcoded to 5555
25+
`,
26+
Args: cobra.ExactArgs(1),
27+
RunE: func(cmd *cobra.Command, args []string) error {
28+
ctx := cmd.Context()
29+
img := args[0]
30+
31+
client, err := bmc.NewClient(ctx, *bmcClientConfig)
32+
if err != nil {
33+
return err
34+
}
35+
defer client.Close()
36+
37+
return client.Boot(ctx, img)
38+
},
39+
}
40+
41+
addBmcClientConfigFlags(&cmd, bmcClientConfig)
42+
43+
return &cmd
44+
}

cmd/bmctl/main.go

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,47 +5,64 @@ import (
55
"log/slog"
66
"os"
77

8+
"github.com/GSI-HPC/bmctl/pkg/bmc"
89
"github.com/GSI-HPC/bmctl/pkg/cli"
9-
_logging "github.com/GSI-HPC/bmctl/pkg/logging"
10+
"github.com/GSI-HPC/bmctl/pkg/logging"
1011
"github.com/spf13/cobra"
1112
)
1213

13-
var showDebug = false
14-
15-
func logLevel() slog.Level {
16-
if showDebug {
17-
return slog.LevelDebug
14+
func newRootCmd(showDebug *bool) *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "bmctl",
17+
Short: "Out-of-band datacenter device management via the BMC interface",
18+
Long: ``,
19+
PersistentPreRun: func(cmd *cobra.Command, args []string) {
20+
var logger *slog.Logger
21+
if *showDebug {
22+
logger = logging.NewLogger(slog.LevelDebug)
23+
} else {
24+
logger = logging.NewLogger(slog.LevelInfo)
25+
}
26+
ctx := logging.WithLogger(cmd.Context(), logger)
27+
parent := cmd
28+
for parent != nil {
29+
parent.SetContext(ctx)
30+
parent = parent.Parent()
31+
}
32+
},
1833
}
19-
return slog.LevelInfo
34+
cmd.PersistentFlags().BoolVarP(showDebug, "debug", "d", false, "show debug logs")
35+
36+
return cmd
2037
}
2138

22-
func setupLogging(cmd *cobra.Command, args []string) {
23-
opts := &slog.HandlerOptions{Level: logLevel()}
24-
handler := slog.NewTextHandler(os.Stderr, opts)
25-
logger := slog.New(handler)
26-
ctx := _logging.WithLogger(cmd.Context(), logger)
27-
parent := cmd
28-
for parent != nil {
29-
parent.SetContext(ctx)
30-
parent = parent.Parent()
39+
func addBmcClientConfigFlags(cmd *cobra.Command, cfg *bmc.ClientConfig) {
40+
cmd.Flags().StringVarP(&cfg.Endpoint, "endpoint", "e", "", "BMC Endpoint (DNS Name or IP)")
41+
cmd.Flags().StringVarP(&cfg.User, "user", "u", "", "BMC User")
42+
cmd.Flags().StringVarP(&cfg.Password, "password", "p", "", "BMC Password")
43+
cmd.Flags().BoolVarP(&cfg.Insecure, "insecure", "k", false, "Ignore validity of BMC TLS Cert")
44+
cmd.Flags().StringVarP(&cfg.SSHProxy, "ssh-proxy", "J", "", "BMC SSH Proxy")
45+
cmd.MarkFlagsRequiredTogether("user", "password")
46+
47+
if err := cmd.MarkFlagRequired("endpoint"); err != nil {
48+
panic(err)
3149
}
32-
}
3350

34-
func newRootCmd() *cobra.Command {
35-
cmd := &cobra.Command{
36-
Use: "bmctl",
37-
Short: "Out-of-band datacenter device management via the BMC interface",
38-
Long: ``,
39-
PersistentPreRun: setupLogging,
51+
if err := cmd.MarkFlagRequired("user"); err != nil {
52+
panic(err)
4053
}
41-
cmd.PersistentFlags().BoolVarP(&showDebug, "debug", "d", false, "show debug logs")
42-
return cmd
4354
}
4455

4556
func main() {
57+
var (
58+
showDebug bool
59+
bmcClientConfig bmc.ClientConfig
60+
)
61+
4662
ctx := cli.SignalContext()
4763

48-
rootCmd := newRootCmd()
64+
rootCmd := newRootCmd(&showDebug)
65+
rootCmd.AddCommand(newBootCmd(&bmcClientConfig))
4966
rootCmd.AddCommand(newVersionCmd())
5067

5168
os.Exit(cli.Execute(ctx, rootCmd))

cmd/bmctl/version.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,20 @@ func newVersionCmd() *cobra.Command {
1515
Long: ``,
1616
RunE: version,
1717
}
18+
1819
return cmd
1920
}
2021

2122
func version(cmd *cobra.Command, args []string) error {
2223
cmd.SetOut(os.Stdout)
24+
2325
info, ok := debug.ReadBuildInfo()
26+
2427
if !ok {
2528
return errors.New("could not read embedded build info ('go build -buildvcs=true')")
2629
}
30+
2731
cmd.Println(info.Main.Version)
32+
2833
return nil
2934
}

go.mod

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,35 @@
1-
// SPDX-FileCopyrightText: 2025 GSI Helmholtzzentrum für Schwerionenforschung GmbH <https://www.gsi.de/en/>
2-
//
3-
// SPDX-License-Identifier: LGPL-3.0-or-later
4-
51
module github.com/GSI-HPC/bmctl
62

7-
go 1.23.9
3+
go 1.24
4+
5+
toolchain go1.24.4
86

97
require (
8+
github.com/cenkalti/backoff/v5 v5.0.2
109
github.com/spf13/cobra v1.9.1
10+
github.com/stmcginnis/gofish v0.20.0
1111
github.com/stretchr/testify v1.10.0
12+
go.abhg.dev/log/silog v0.1.0
13+
golang.org/x/net v0.41.0
1214
golang.org/x/sys v0.33.0
1315
)
1416

1517
require (
18+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
19+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
20+
github.com/charmbracelet/lipgloss v1.1.0 // indirect
21+
github.com/charmbracelet/x/ansi v0.8.0 // indirect
22+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
23+
github.com/charmbracelet/x/term v0.2.1 // indirect
1624
github.com/davecgh/go-spew v1.1.1 // indirect
1725
github.com/inconshreveable/mousetrap v1.1.0 // indirect
26+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
27+
github.com/mattn/go-isatty v0.0.20 // indirect
28+
github.com/mattn/go-runewidth v0.0.16 // indirect
29+
github.com/muesli/termenv v0.16.0 // indirect
1830
github.com/pmezard/go-difflib v1.0.0 // indirect
31+
github.com/rivo/uniseg v0.4.7 // indirect
1932
github.com/spf13/pflag v1.0.6 // indirect
33+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
2034
gopkg.in/yaml.v3 v3.0.1 // indirect
2135
)

go.sum

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,53 @@
1+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
2+
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
3+
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
4+
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
5+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
6+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
7+
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
8+
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
9+
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
10+
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
11+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
12+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
13+
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
14+
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
115
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
216
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
317
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
418
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
519
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
20+
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
21+
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
22+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
23+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
24+
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
25+
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
26+
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
27+
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
628
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
729
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
30+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
31+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
32+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
833
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
934
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
1035
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
1136
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
1237
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
38+
github.com/stmcginnis/gofish v0.20.0 h1:hH2V2Qe898F2wWT1loApnkDUrXXiLKqbSlMaH3Y1n08=
39+
github.com/stmcginnis/gofish v0.20.0/go.mod h1:PzF5i8ecRG9A2ol8XT64npKUunyraJ+7t0kYMpQAtqU=
1340
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
1441
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
42+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
43+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
44+
go.abhg.dev/log/silog v0.1.0 h1:sTX2OQoCPD/gqzYInA2qBTG5/qxWQzumJXnEnGvsvTs=
45+
go.abhg.dev/log/silog v0.1.0/go.mod h1:Vgqa6nHloXreG/TQCYG2CHofhHPCm8DEVv80WnxPYTs=
46+
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
47+
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
48+
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
49+
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
50+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
1551
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
1652
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
1753
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

pkg/bmc/bmc.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Package bmc provides the client API to interact with a Baseboard Management Controller
2+
package bmc

pkg/bmc/client.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package bmc
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
8+
"github.com/GSI-HPC/bmctl/pkg/logging"
9+
"github.com/GSI-HPC/bmctl/pkg/ssh"
10+
"github.com/stmcginnis/gofish"
11+
)
12+
13+
// ClientConfig holds configuration for connecting to a BMC endpoint.
14+
type ClientConfig struct {
15+
Endpoint string
16+
User string
17+
Password string
18+
Insecure bool
19+
SSHProxy string
20+
}
21+
22+
// Client provides methods to interact with a BMC.
23+
type Client struct {
24+
closeProxy ssh.ProxyCloser
25+
gofish *gofish.APIClient
26+
logger *slog.Logger
27+
}
28+
29+
// NewClient creates a new Client with the given configuration.
30+
func NewClient(ctx context.Context, cfg ClientConfig) (*Client, error) {
31+
dialer, closeProxy, err := ssh.NewProxyDialer(ctx, cfg.SSHProxy)
32+
if err != nil {
33+
closeProxy()
34+
35+
return nil, err
36+
}
37+
38+
httpClient, err := newHTTPClient(cfg.Insecure, dialer)
39+
if err != nil {
40+
closeProxy()
41+
42+
return nil, err
43+
}
44+
45+
gofishCfg := gofish.ClientConfig{
46+
Endpoint: "https://" + cfg.Endpoint,
47+
Username: cfg.User,
48+
Password: cfg.Password,
49+
BasicAuth: false,
50+
HTTPClient: httpClient,
51+
}
52+
53+
gofishClient, err := gofish.ConnectContext(ctx, gofishCfg)
54+
if err != nil {
55+
closeProxy()
56+
57+
return nil, err
58+
}
59+
60+
logger := logging.FromContext(ctx).
61+
With(slog.String("bmc_client", fmt.Sprintf("%s@%s", cfg.User, cfg.Endpoint)))
62+
logger.Debug("BMC connected")
63+
64+
return &Client{
65+
closeProxy: closeProxy,
66+
gofish: gofishClient,
67+
logger: logger,
68+
}, nil
69+
}
70+
71+
// Boot performs a BMC initiated virtual media boot.
72+
func (c *Client) Boot(ctx context.Context, img string) error {
73+
c.logger.Info(c.gofish.GetService().RedfishVersion)
74+
75+
return nil
76+
}
77+
78+
// Close releases any resources held by the Client.
79+
func (c *Client) Close() {
80+
c.gofish.Logout()
81+
c.logger.Debug("BMC disconnected")
82+
c.closeProxy()
83+
}

0 commit comments

Comments
 (0)