Skip to content

Commit

Permalink
Add metalctl console subcommand
Browse files Browse the repository at this point in the history
- Add a `metalctl console` subcommand with which you are able to connect
  to a `Servers` serial console
- Factor out `bmcutils` into an own package
  • Loading branch information
afritzler committed Dec 9, 2024
1 parent 3fa1f4e commit 600f99e
Show file tree
Hide file tree
Showing 13 changed files with 556 additions and 31 deletions.
1 change: 1 addition & 0 deletions cmd/metalctl/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ func NewCommand() *cobra.Command {
Args: cobra.NoArgs,
}
root.AddCommand(NewMoveCommand())
root.AddCommand(NewConsoleCommand())
return root
}
176 changes: 176 additions & 0 deletions cmd/metalctl/app/console.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

package app

import (
"context"
"fmt"
"io"
"log"
"log/slog"
"net"
"os"

"github.com/ironcore-dev/metal-operator/internal/console"

"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/config"
)

var (
kubeconfigPath string
kubeconfig string
serialConsoleNumber int
)

func NewConsoleCommand() *cobra.Command {
consoleCmd := &cobra.Command{
Use: "console",
Short: "Access the serial console of a Server",
RunE: runConsole,
}

consoleCmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "Path to a kubeconfig.")
consoleCmd.Flags().IntVar(&serialConsoleNumber, "serial-console-number", 1, "Serial console number.")

if verbose {
slog.SetLogLoggerLevel(slog.LevelDebug)
}
return consoleCmd
}

func runConsole(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return fmt.Errorf("server name is required")
}
var serverName string
if len(args) > 1 {
return fmt.Errorf("too many arguments")
}
serverName = args[0]

k8sClient, err := createClient()
if err != nil {
return err
}

if err := openConsoleStream(cmd.Context(), k8sClient, serverName); err != nil {
return err
}

return nil
}

func openConsoleStream(ctx context.Context, k8sClient client.Client, serverName string) error {
consoleConfig, err := console.GetConfigForServerName(ctx, k8sClient, serverName)
if err != nil {
return fmt.Errorf("failed to get console config: %w", err)
}
if consoleConfig == nil {
return fmt.Errorf("console config is nil")
}

// Create SSH client configuration
sshConfig := &ssh.ClientConfig{
User: consoleConfig.Username,
Auth: []ssh.AuthMethod{
ssh.Password(consoleConfig.Password),
},
// TODO: use proper key verification
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}

// Connect to the BMC
bmcAddress := net.JoinHostPort(consoleConfig.BMCAddress, "22")
conn, err := ssh.Dial("tcp", bmcAddress, sshConfig)
if err != nil {
return fmt.Errorf("failed to connect to BMC: %w", err)
}
defer func(conn *ssh.Client) {
if err = conn.Close(); err != nil {
log.Printf("failed to close SSH connection: %v", err)
}
}(conn)

// Start a session
session, err := conn.NewSession()
if err != nil {
return fmt.Errorf("failed to create SSH session: %w", err)
}
defer func(session *ssh.Session) {
if err = session.Close(); err != nil {
log.Printf("failed to close SSH session: %v", err)
}
}(session)

// Request a pseudo-terminal for interactive sessions
if err = session.RequestPty("xterm", 80, 40, ssh.TerminalModes{
ssh.ECHO: 0, // Disable echo
ssh.TTY_OP_ISPEED: 14400, // Input speed
ssh.TTY_OP_OSPEED: 14400, // Output speed
}); err != nil {
return fmt.Errorf("failed to request pseudo-terminal failed: %w", err)
}

// Start the SOL session
stdin, err := session.StdinPipe()
if err != nil {
return fmt.Errorf("could not get stdin pipe: %w", err)
}
stdout, err := session.StdoutPipe()
if err != nil {
return fmt.Errorf("could not get stdout pipe: %w", err)
}

go func() {
_, err = io.Copy(os.Stdout, stdout)
if err != nil {
log.Printf("failed to copy stdout: %s", err)
}
}() // Stream the SOL output to the terminal

if err = session.Start(fmt.Sprintf("console %d", serialConsoleNumber)); err != nil {
return fmt.Errorf("failed to start SOL command: %w", err)
}

log.Println("Serial-over-LAN session active. Press Ctrl+C to exit.")
go func() {
// Allow sending input to the session
_, err = io.Copy(stdin, os.Stdin)
if err != nil {
log.Printf("failed to copy stdin: %s", err)
}
}()

// Wait for the session to end
if err := session.Wait(); err != nil {
return fmt.Errorf("error during SOL session: %v", err)
}
return nil
}

func createClient() (client.Client, error) {
if kubeconfig != "" {
kubeconfigPath = kubeconfig
} else {
kubeconfigPath = os.Getenv("KUBECONFIG")
if kubeconfigPath == "" {
fmt.Println("Error: --kubeconfig flag or KUBECONFIG environment variable must be set")
os.Exit(1)
}
}

clientConfig, err := config.GetConfigWithContext("")
if err != nil {
return nil, fmt.Errorf("failed getting client config: %w", err)
}

k8sClient, err := client.New(clientConfig, client.Options{Scheme: scheme})
if err != nil {
return nil, fmt.Errorf("failed creating controller-runtime client: %w", err)
}
return k8sClient, nil
}
15 changes: 15 additions & 0 deletions docs/usage/metalctl.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ go install https://github.com/ironcore-dev/metal-operator/cmd/metalctl@latest

## Commands

### console

The `metalctl console` command allows you to access the serial console of a `Server`.

To open a connection to the `Servers` serial console run

```bash
metalctl console my-server
```

In order to authenticate against the API server you need either to provide a path to a `kubeconfig` via `--kubeconfig`
or set the `KUBECONFIG` environment variable by pointing to an effective `kubeconfig` file.

By default, the serial console on `ttyS1` will be opened. You can override this by setting `--serial-console-number`.

### move

The `metalctl move` command allows to move the metal Custom Resources, like e.g. `Endpoint`, `BMC`, `Server`, etc. from one
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ require (
github.com/ironcore-dev/controller-utils v0.9.5
github.com/onsi/ginkgo/v2 v2.22.0
github.com/onsi/gomega v1.36.0
github.com/stmcginnis/gofish v0.20.0
github.com/spf13/cobra v1.8.1
github.com/stmcginnis/gofish v0.20.0
golang.org/x/crypto v0.28.0
k8s.io/api v0.31.1
k8s.io/apiextensions-apiserver v0.31.0
k8s.io/apimachinery v0.31.1
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
github.com/ironcore-dev/controller-utils v0.9.5 h1:vdUnCNolC0uDMZy5d8Noib5kdqdK9YpelVSgPc30qTc=
github.com/ironcore-dev/controller-utils v0.9.5/go.mod h1:pzrmJmc6LXtn48cTAgKHm5i8ry6q1Qw7SUh0XaYfIP4=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/ironcore-dev/controller-utils v0.9.5 h1:vdUnCNolC0uDMZy5d8Noib5kdqdK9YpelVSgPc30qTc=
github.com/ironcore-dev/controller-utils v0.9.5/go.mod h1:pzrmJmc6LXtn48cTAgKHm5i8ry6q1Qw7SUh0XaYfIP4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
Expand Down Expand Up @@ -129,6 +129,8 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
Expand Down
65 changes: 50 additions & 15 deletions internal/controller/bmcutils.go → internal/bmcutils/bmcutils.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,66 @@
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

package controller
package bmcutils

import (
"context"
"fmt"
"net"

metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
"github.com/ironcore-dev/metal-operator/bmc"

metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
"sigs.k8s.io/controller-runtime/pkg/client"
)

func GetBMCCredentialsFromSecret(secret *metalv1alpha1.BMCSecret) (string, string, error) {
// TODO: use constants for secret keys
username, ok := secret.Data["username"]
if !ok {
return "", "", fmt.Errorf("no username found in the BMC secret")
}
password, ok := secret.Data["password"]
if !ok {
return "", "", fmt.Errorf("no password found in the BMC secret")
}
return string(username), string(password), nil
}

func GetBMCFromBMCName(ctx context.Context, c client.Client, bmcName string) (*metalv1alpha1.BMC, error) {
bmcObj := &metalv1alpha1.BMC{}
if err := c.Get(ctx, client.ObjectKey{Name: bmcName}, bmcObj); err != nil {
return nil, fmt.Errorf("failed to get bmc %q: %w", bmcName, err)
}
return bmcObj, nil
}

func GetBMCCredentialsForBMCSecretName(ctx context.Context, c client.Client, bmcSecretName string) (string, string, error) {
bmcSecret := &metalv1alpha1.BMCSecret{}
if err := c.Get(ctx, client.ObjectKey{Name: bmcSecretName}, bmcSecret); err != nil {
return "", "", fmt.Errorf("failed to get bmc secret: %w", err)
}
return GetBMCCredentialsFromSecret(bmcSecret)
}

func GetBMCAddressForBMC(ctx context.Context, c client.Client, bmcObj *metalv1alpha1.BMC) (string, error) {
var address string

if bmcObj.Spec.EndpointRef != nil {
endpoint := &metalv1alpha1.Endpoint{}
if err := c.Get(ctx, client.ObjectKey{Name: bmcObj.Spec.EndpointRef.Name}, endpoint); err != nil {
return "", fmt.Errorf("failed to get Endpoints for BMC: %w", err)
}
return endpoint.Spec.IP.String(), nil
}

if bmcObj.Spec.Endpoint != nil {
return bmcObj.Spec.Endpoint.IP.String(), nil
}

return address, nil
}

const DefaultKubeNamespace = "default"

func GetBMCClientForServer(ctx context.Context, c client.Client, server *metalv1alpha1.Server, insecure bool, options bmc.BMCOptions) (bmc.BMC, error) {
Expand Down Expand Up @@ -124,19 +172,6 @@ func CreateBMCClient(
return bmcClient, nil
}

func GetBMCCredentialsFromSecret(secret *metalv1alpha1.BMCSecret) (string, string, error) {
// TODO: use constants for secret keys
username, ok := secret.Data["username"]
if !ok {
return "", "", fmt.Errorf("no username found in the BMC secret")
}
password, ok := secret.Data["password"]
if !ok {
return "", "", fmt.Errorf("no password found in the BMC secret")
}
return string(username), string(password), nil
}

func GetServerNameFromBMCandIndex(index int, bmc *metalv1alpha1.BMC) string {
return fmt.Sprintf("%s-%s-%d", bmc.Name, "system", index)
}
63 changes: 63 additions & 0 deletions internal/console/console.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

package console

import (
"context"
"fmt"

"github.com/ironcore-dev/metal-operator/api/v1alpha1"
"github.com/ironcore-dev/metal-operator/internal/bmcutils"
"sigs.k8s.io/controller-runtime/pkg/client"
)

type Config struct {
BMCAddress string
Username string
Password string
}

func GetConfigForServerName(ctx context.Context, c client.Client, serverName string) (*Config, error) {
server := &v1alpha1.Server{}
if err := c.Get(ctx, client.ObjectKey{Name: serverName}, server); err != nil {
return nil, fmt.Errorf("failed to get server %q: %w", serverName, err)
}

// Inline BMC configuration
if server.Spec.BMC != nil {
username, password, err := bmcutils.GetBMCCredentialsForBMCSecretName(ctx, c, server.Spec.BMC.BMCSecretRef.Name)
if err != nil {
return nil, err
}
return &Config{
BMCAddress: server.Spec.BMC.Address,
Username: username,
Password: password,
}, nil
}

// BMC by reference
if server.Spec.BMCRef != nil {
bmc, err := bmcutils.GetBMCFromBMCName(ctx, c, server.Spec.BMCRef.Name)
if err != nil {
return nil, err
}
username, password, err := bmcutils.GetBMCCredentialsForBMCSecretName(ctx, c, bmc.Spec.BMCSecretRef.Name)
if err != nil {
return nil, err
}
address, err := bmcutils.GetBMCAddressForBMC(ctx, c, bmc)
if err != nil {
return nil, err
}

return &Config{
BMCAddress: address,
Username: username,
Password: password,
}, nil
}

return nil, nil
}
Loading

0 comments on commit 600f99e

Please sign in to comment.