Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ env:

jobs:
release:
name: Build and Push to ghcr.io
runs-on: ubuntu-latest
strategy:
matrix:
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/reuse.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
# SPDX-License-Identifier: CC0-1.0

---
name: REUSE Compliance Check
name: REUSE

on: [push, pull_request]

permissions:
contents: read

jobs:
reuse-compliance-check:
compliance-check:
name: Compliance Check
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,22 @@
# SPDX-License-Identifier: CC0-1.0

---
name: Build and Test
name: Go

on: [push, pull_request]

permissions:
contents: read
checks: write
id-token: write

jobs:
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ['1.21', '1.22', '1.23', '1.24']
go-version: ['1.24']
fail-fast: false

steps:
Expand Down Expand Up @@ -51,6 +53,7 @@ jobs:
report_paths: 'junit-report.xml'

build:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
Expand Down Expand Up @@ -88,6 +91,7 @@ jobs:
retention-days: 30

lint:
name: Lint
runs-on: ubuntu-latest
strategy:
matrix:
Expand Down
6 changes: 2 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
# SPDX-FileCopyrightText: 2025 GSI Helmholtzzentrum für Schwerionenforschung GmbH <https://www.gsi.de/en/>
#
# SPDX-License-Identifier: CC0-1.0

# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
Expand Down Expand Up @@ -36,3 +32,5 @@ go.work.sum
# .vscode/

/bmctl
.claude/
CLAUDE.md
49 changes: 49 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
version: "2"
linters:
default: all
disable:
- depguard
- err113
- exhaustruct
- testpackage
- paralleltest
- wrapcheck
- nolintlint
- wsl
settings:
errcheck:
check-type-assertions: true
check-blank: true
gocyclo:
min-complexity: 15
goconst:
min-len: 3
min-occurrences: 3
lll:
line-length: 120
misspell:
locale: US
mnd:
checks:
- argument
- case
- condition
- operation
- return
revive:
severity: warning
rules:
- name: exported
- name: package-comments
- name: var-naming
varnamelen:
ignore-decls:
- t testing.T
- T any
- ok bool
- w http.ResponseWriter
- r *http.Request
wsl_v5:
allow-first-in-block: true
allow-whole-block: false
branch-max-lines: 2
53 changes: 53 additions & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# SPDX-FileCopyrightText: 2025 GSI Helmholtzzentrum für Schwerionenforschung GmbH <https://www.gsi.de/en/>
#
# SPDX-License-Identifier: CC0-1.0

version = 1

[[annotations]]
path = [
".gitignore",
".golangci.yml",
"docs/reference.md"
]
SPDX-FileCopyrightText = "2025 GSI Helmholtzzentrum für Schwerionenforschung GmbH <https://www.gsi.de/en/>"
SPDX-License-Identifier = "CC0-1.0"

[[annotations]]
path = [
"cmd/bmctl/boot.go",
"cmd/bmctl/main.go",
"cmd/bmctl/main_test.go",
"cmd/bmctl/version.go",
"cmd/bmctl/version_test.go",
"go.mod",
"go.sum",
"pkg/bmc/bmc.go",
"pkg/bmc/client.go",
"pkg/bmc/client_test.go",
"pkg/bmc/http.go",
"pkg/bmc/http_test.go",
"pkg/cli/cli.go",
"pkg/cli/errors.go",
"pkg/cli/errors_test.go",
"pkg/cli/execute.go",
"pkg/cli/execute_test.go",
"pkg/cli/flags.go",
"pkg/cli/flags_test.go",
"pkg/cli/signals.go",
"pkg/cli/signals_test.go",
"pkg/logging/context.go",
"pkg/logging/context_test.go",
"pkg/logging/logger.go",
"pkg/logging/logging.go",
"pkg/ssh/proxy.go",
"pkg/ssh/proxy_test.go",
"pkg/ssh/ssh.go",
"pkg/testing/capture.go",
"pkg/testing/capture_test.go",
"pkg/testing/fork.go",
"pkg/testing/fork_test.go",
"pkg/testing/testing.go",
]
SPDX-FileCopyrightText = "2025 GSI Helmholtzzentrum für Schwerionenforschung GmbH <https://www.gsi.de/en/>"
SPDX-License-Identifier = "LGPL-3.0-or-later"
49 changes: 49 additions & 0 deletions cmd/bmctl/boot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package main

import (
"github.com/GSI-HPC/bmctl/pkg/bmc"
"github.com/spf13/cobra"
)

func newBootCmd(bmcClientConfig *bmc.ClientConfig) *cobra.Command {
cmd := cobra.Command{
Use: "boot IMAGE",
Short: "Boot an image file",
Long: `Out-of-band initiated device boot

1. Create SSH tunnel, if SSH proxy is set (expects OpenSSH ssh command in $PATH)
2. Connect to and authenticate with BMC
3. Start HTTPS server serving given image file
4. Insert image URL as virtual medium
5. Set next boot target to this virtual medium
6. Reboot the device
7. Wait until Ctrl+C, after which the HTTPS server is shut down

Limitations (currently):
* Works for the first device per BMC only
* local SSH Proxy Port hardcoded to 5555
`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
img := args[0]

err := validateBmcClientConfig(bmcClientConfig)
if err != nil {
return err
}

client, err := bmc.NewClient(ctx, *bmcClientConfig)
if err != nil {
return err
}
defer client.Close()

return client.Boot(ctx, img)
},
}

addBmcClientConfigFlags(&cmd, bmcClientConfig)

return &cmd
}
114 changes: 85 additions & 29 deletions cmd/bmctl/main.go
Original file line number Diff line number Diff line change
@@ -1,54 +1,110 @@
// SPDX-FileCopyrightText: 2025 GSI Helmholtzzentrum für Schwerionenforschung GmbH <https://www.gsi.de/en/>
//
// SPDX-License-Identifier: LGPL-3.0-or-later

// Package main is the entry point for the bmctl command-line tool.
package main

import (
"errors"
"fmt"
"log/slog"
"os"

"github.com/GSI-HPC/bmctl/pkg/bmc"
"github.com/GSI-HPC/bmctl/pkg/cli"
_logging "github.com/GSI-HPC/bmctl/pkg/logging"
"github.com/GSI-HPC/bmctl/pkg/logging"
"github.com/spf13/cobra"
)

var showDebug = false

func logLevel() slog.Level {
if showDebug {
return slog.LevelDebug
func newRootCmd(showDebug *bool) *cobra.Command {
cmd := &cobra.Command{
Use: "bmctl",
Short: "Out-of-band datacenter device management via the BMC interface",
Long: ``,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
var logger *slog.Logger
if *showDebug {
logger = logging.NewLogger(slog.LevelDebug)
} else {
logger = logging.NewLogger(slog.LevelInfo)
}
ctx := logging.WithLogger(cmd.Context(), logger)
parent := cmd
for parent != nil {
parent.SetContext(ctx)
parent = parent.Parent()
}
},
}
return slog.LevelInfo
cmd.PersistentFlags().BoolVarP(showDebug, "debug", "d", false, "show debug logs")

return cmd
}

func setupLogging(cmd *cobra.Command, args []string) {
opts := &slog.HandlerOptions{Level: logLevel()}
handler := slog.NewTextHandler(os.Stderr, opts)
logger := slog.New(handler)
ctx := _logging.WithLogger(cmd.Context(), logger)
parent := cmd
for parent != nil {
parent.SetContext(ctx)
parent = parent.Parent()
// addBmcClientConfigFlags adds command-line flags for configuring the BMC client.
func addBmcClientConfigFlags(cmd *cobra.Command, cfg *bmc.ClientConfig) {
cmd.Flags().VarP((*cli.URLFlag)(&cfg.Endpoint), "endpoint", "e", "BMC Endpoint (FQDN or IP)")
cmd.Flags().StringVarP(&cfg.User, "user", "u", "", "BMC User")
cmd.Flags().StringVarP(&cfg.Password, "password", "p", "", "BMC Password")
cmd.Flags().BoolVarP(&cfg.Insecure, "insecure", "k", false, "Ignore validity of BMC TLS Cert")
cmd.Flags().StringVarP(&cfg.SSHProxy, "ssh-proxy", "J", "", "BMC SSH Proxy")
cmd.MarkFlagsRequiredTogether("user", "password")

err := cmd.MarkFlagRequired("endpoint")
if err != nil {
panic(err)
}

err = cmd.MarkFlagRequired("user")
if err != nil {
panic(err)
}
}

func newRootCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "bmctl",
Short: "Out-of-band datacenter device management via the BMC interface",
Long: ``,
PersistentPreRun: setupLogging,
// validateBmcClientConfig validates the BMC client configuration which is initialized by user input.
// Returns an error if any configuration is invalid.
func validateBmcClientConfig(cfg *bmc.ClientConfig) error {
switch scheme := cfg.Endpoint.Scheme; scheme {
case "https", "http":
// valid
default:
return errors.New("endpoint must be a valid http(s) URL")
}
cmd.PersistentFlags().BoolVarP(&showDebug, "debug", "d", false, "show debug logs")
return cmd

// Validate string field lengths
const (
maxEndpointLength = 253 // RFC 1035 hostname limit
maxUserLength = 64 // Common username length limit
maxPasswordLength = 128 // Reasonable password length limit
maxSSHProxyLength = 253 // Same as hostname for SSH proxy
)

if len(cfg.Endpoint.String()) > maxEndpointLength {
return fmt.Errorf("endpoint URL too long (max %d characters)", maxEndpointLength)
}

if len(cfg.User) > maxUserLength {
return fmt.Errorf("user too long (max %d characters)", maxUserLength)
}

if len(cfg.Password) > maxPasswordLength {
return fmt.Errorf("password too long (max %d characters)", maxPasswordLength)
}

if len(cfg.SSHProxy) > maxSSHProxyLength {
return fmt.Errorf("ssh-proxy too long (max %d characters)", maxSSHProxyLength)
}

return nil
}

func main() {
var (
showDebug bool
bmcClientConfig bmc.ClientConfig
)

ctx := cli.SignalContext()

rootCmd := newRootCmd()
rootCmd := newRootCmd(&showDebug)
rootCmd.AddCommand(newBootCmd(&bmcClientConfig))
rootCmd.AddCommand(newVersionCmd())

os.Exit(cli.Execute(ctx, rootCmd))
Expand Down
Loading
Loading