Add Redfish support to Jump Host service

This change brings Redfish support to the Jump Host service, enabling
sub-cluster operators to reboot virtual machines in their sub-cluster.
With a Secret containing sub-cluster host information (e.g. BMC
addresses, BMC usernames, BMC password) and a ConfigMap containing a
wrapper script for DMTF's redfishtool, a user in a sub-cluster's Jump
Pod can manage their hosts by executing /sip/scripts/reboot [CMD].

Signed-off-by: Drew Walters <andrew.walters@att.com>
Change-Id: Iff71ad2287cb095ebe92445a4a09771697efa5ee
This commit is contained in:
Drew Walters 2021-02-12 16:34:49 +00:00
parent 00c45a4444
commit fe4c9a7221
10 changed files with 398 additions and 16 deletions

View File

@ -103,6 +103,12 @@ spec:
description: JumpHostService is an infrastructure service type
that represents the sub-cluster jump-host service.
properties:
bmc:
description: BMCOpts contains options for BMC communication.
properties:
proxy:
type: boolean
type: object
inline:
properties:
clusterIP:

View File

@ -68,6 +68,7 @@ rules:
- apiGroups:
- ""
resources:
- configmaps
- services
verbs:
- create

View File

@ -28,16 +28,14 @@ spec:
nodePort: 7023
nodeInterfaceId: oam-ipv4
- serviceType: jumphost
optional:
sshKey: rsa.... #<-- this needs to align to the ssh keys provided to cluster api objects
image: ubuntu:20.04
redfish:
proxy: false
image: quay.io/airshipit/jump-host:dev
nodeLabels:
- airship-control-plane
nodePort: 7022
nodeInterfaceId: oam-ipv4
- serviceType: loadbalancer
optional:
clusterIP: 1.2.3.4 #<-- this aligns to the VIP IP for undercloud k8s
image: haproxy:2.3.2
nodeLabels:
- airship-control-plane

View File

@ -9,6 +9,37 @@
<p>Package v1 contains API Schema definitions for the airship v1 API group</p>
Resource Types:
<ul class="simple"></ul>
<h3 id="airship.airshipit.org/v1.BMCOpts">BMCOpts
</h3>
<p>
(<em>Appears on:</em>
<a href="#airship.airshipit.org/v1.JumpHostService">JumpHostService</a>)
</p>
<p>BMCOpts contains options for BMC communication.</p>
<div class="md-typeset__scrollwrap">
<div class="md-typeset__table">
<table>
<thead>
<tr>
<th>Field</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>proxy</code><br>
<em>
bool
</em>
</td>
<td>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<h3 id="airship.airshipit.org/v1.JumpHostService">JumpHostService
</h3>
<p>
@ -40,6 +71,18 @@ SIPClusterService
</tr>
<tr>
<td>
<code>bmc</code><br>
<em>
<a href="#airship.airshipit.org/v1.BMCOpts">
BMCOpts
</a>
</em>
</td>
<td>
</td>
</tr>
<tr>
<td>
<code>sshkey</code><br>
<em>
string

View File

@ -86,7 +86,8 @@ func (s SIPClusterServices) GetAll() []SIPClusterService {
// JumpHostService is an infrastructure service type that represents the sub-cluster jump-host service.
type JumpHostService struct {
SIPClusterService `json:"inline"`
SSHKey string `json:"sshkey,omitempty"`
BMC *BMCOpts `json:"bmc,omitempty"`
SSHKey string `json:"sshkey,omitempty"`
}
// SIPClusterStatus defines the observed state of SIPCluster
@ -165,6 +166,11 @@ type SIPClusterService struct {
ClusterIP *string `json:"clusterIP,omitempty"`
}
// BMCOpts contains options for BMC communication.
type BMCOpts struct {
Proxy bool `json:"proxy,omitempty"`
}
// VMRole defines the states the provisioner will report
// the tenant has having.
type VMRole string

View File

@ -25,10 +25,30 @@ import (
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *BMCOpts) DeepCopyInto(out *BMCOpts) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BMCOpts.
func (in *BMCOpts) DeepCopy() *BMCOpts {
if in == nil {
return nil
}
out := new(BMCOpts)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *JumpHostService) DeepCopyInto(out *JumpHostService) {
*out = *in
in.SIPClusterService.DeepCopyInto(&out.SIPClusterService)
if in.BMC != nil {
in, out := &in.BMC, &out.BMC
*out = new(BMCOpts)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JumpHostService.

View File

@ -26,5 +26,14 @@ type ErrInfraServiceNotSupported struct {
}
func (e ErrInfraServiceNotSupported) Error() string {
return fmt.Sprintf("Invalid Infrastructure Service: %v", e.Service)
return fmt.Sprintf("invalid Infrastructure Service: %v", e.Service)
}
// ErrMalformedRedfishAddress occurs when a Redfish address does not meet the expected format.
type ErrMalformedRedfishAddress struct {
Address string
}
func (e ErrMalformedRedfishAddress) Error() string {
return fmt.Sprintf("invalid Redfish BMC address %s", e.Address)
}

View File

@ -15,6 +15,10 @@
package services
import (
"encoding/json"
"fmt"
"net/url"
"github.com/go-logr/logr"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
@ -28,9 +32,15 @@ import (
const (
JumpHostServiceName = "jumphost"
mountPathData = "/etc/opt/sip"
mountPathScripts = "/opt/sip/bin"
nameHostsVolume = "hosts"
nameRebootVolume = "vm"
)
// JumpHost is an InfrastructureService that provides SSH capabilities to access a sub-cluster.
// JumpHost is an InfrastructureService that provides SSH and power-management capabilities for sub-clusters.
type jumpHost struct {
client client.Client
sipName types.NamespacedName
@ -82,10 +92,75 @@ func (jh jumpHost) Deploy() error {
return err
}
// TODO: Validate Secret becomes ready.
secret, err := jh.generateSecret(instance, labels)
if err != nil {
return err
}
jh.logger.Info("Applying secret", "secret", secret.GetNamespace()+"/"+secret.GetName())
err = applyRuntimeObject(client.ObjectKey{Name: secret.GetName(), Namespace: secret.GetNamespace()},
secret, jh.client)
if err != nil {
return err
}
// TODO: Validate ConfigMap becomes ready.
configMap := jh.generateConfigMap(instance, labels)
jh.logger.Info("Applying configmap", "configmap", configMap.GetNamespace()+"/"+configMap.GetName())
err = applyRuntimeObject(client.ObjectKey{Name: configMap.GetName(), Namespace: configMap.GetNamespace()},
secret, jh.client)
if err != nil {
return err
}
return nil
}
func (jh jumpHost) generateDeployment(instance string, labels map[string]string) *appsv1.Deployment {
// NOTE(drewwalters96): the jump host container is declared here so environment variables can be easily added
// below based on configuration values in the SIPCluster CR.
jhContainer := corev1.Container{
Name: JumpHostServiceName,
Image: jh.config.Image,
Ports: []corev1.ContainerPort{
{
Name: "ssh",
ContainerPort: 22,
},
},
VolumeMounts: []corev1.VolumeMount{
{
Name: nameHostsVolume,
MountPath: mountPathData,
},
{
Name: nameRebootVolume,
MountPath: mountPathScripts,
},
},
Command: []string{"/bin/sh"},
Args: []string{"-c", "while true; do sleep 30; done"},
}
// Set NO_PROXY env variables when Redfish proxy setting is false (Default: false).
var proxy bool
if jh.config.BMC != nil {
proxy = jh.config.BMC.Proxy
}
if proxy == false {
jhContainer.Env = []corev1.EnvVar{
{
Name: "NO_PROXY",
Value: "*",
},
{
Name: "no_proxy",
Value: "*",
},
}
}
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: instance,
@ -103,13 +178,25 @@ func (jh jumpHost) generateDeployment(instance string, labels map[string]string)
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
jhContainer,
},
Volumes: []corev1.Volume{
{
Name: JumpHostServiceName,
Image: jh.config.Image,
Ports: []corev1.ContainerPort{
{
Name: "ssh",
ContainerPort: 22,
Name: nameHostsVolume,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: instance,
},
},
},
{
Name: nameRebootVolume,
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: instance,
},
DefaultMode: int32Ptr(0777),
},
},
},
@ -121,6 +208,45 @@ func (jh jumpHost) generateDeployment(instance string, labels map[string]string)
}
}
func (jh jumpHost) generateConfigMap(instance string, labels map[string]string) *corev1.ConfigMap {
return &corev1.ConfigMap{
TypeMeta: metav1.TypeMeta{
APIVersion: corev1.SchemeGroupVersion.String(),
Kind: "ConfigMap",
},
ObjectMeta: metav1.ObjectMeta{
Name: instance,
Namespace: jh.sipName.Namespace,
Labels: labels,
},
Data: map[string]string{
nameRebootVolume: fmt.Sprintf(rebootScript, mountPathData, nameHostsVolume),
},
}
}
func (jh jumpHost) generateSecret(instance string, labels map[string]string) (*corev1.Secret, error) {
hostData, err := generateHostList(*jh.machines)
if err != nil {
return nil, err
}
return &corev1.Secret{
TypeMeta: metav1.TypeMeta{
APIVersion: corev1.SchemeGroupVersion.String(),
Kind: "Secret",
},
ObjectMeta: metav1.ObjectMeta{
Name: instance,
Namespace: jh.sipName.Namespace,
Labels: labels,
},
Data: map[string][]byte{
nameHostsVolume: hostData,
},
}, nil
}
func (jh jumpHost) generateHostAliases() []corev1.HostAlias {
hostAliases := []corev1.HostAlias{}
for _, machine := range jh.machines.Machines {
@ -166,6 +292,130 @@ func (jh jumpHost) Finalize() error {
return nil
}
type host struct {
Name string `json:"name"`
BMC bmc `json:"bmc"`
}
type bmc struct {
IP string `json:"ip"`
Username string `json:"username"`
Password string `json:"password"`
}
// generateHostList creates a list of hosts in JSON format to be mounted as a config map to the jump host pod and used
// to power cycle sub-cluster nodes.
func generateHostList(machineList airshipvms.MachineList) ([]byte, error) {
hosts := make([]host, len(machineList.Machines))
for name, machine := range machineList.Machines {
managementIP, err := getManagementIP(machine.BMH.Spec.BMC.Address)
if err != nil {
return nil, err
}
h := host{
Name: name,
BMC: bmc{
IP: managementIP,
Username: machine.Data.BMCUsername,
Password: machine.Data.BMCPassword,
},
}
hosts = append(hosts, h)
}
out, err := json.Marshal(hosts)
if err != nil {
return nil, err
}
return out, nil
}
// getManagementIP parses the BMC IP address from a Redfish fully qualified domain name. For example, the input
// redfish+https://127.0.0.1/redfish/v1/Systems/System.Embedded.1 yields 127.0.0.1.
func getManagementIP(redfishURL string) (string, error) {
parsedURL, err := url.Parse(redfishURL)
if err != nil {
return "", ErrMalformedRedfishAddress{Address: redfishURL}
}
return parsedURL.Host, nil
}
var rebootScript = `#!/bin/sh
# Support Infrastructure Provider (SIP) VM Utility
# DO NOT MODIFY: generated by SIP
HOSTS_FILE="%s/%s"
LIST_COMMAND="list"
REBOOT_COMMAND="reboot"
help() {
echo "Support Infrastructure Provider (SIP) VM Utility"
echo ""
echo "Usage: ${LIST_COMMAND} list hosts"
echo " ${REBOOT_COMMAND} [host name] reboot host"
}
dep_check() {
if [ "$(which jq)" = "" ]; then
echo "Missing package 'jq'. Update your JumpHost image to include 'jq' and 'redfishtool'."
exit 1
fi
if [ "$(which redfishtool)" = "" ]; then
echo "Missing package 'redfishtool'. Update your JumpHost image to include 'jq' and 'redfishtool'."
exit 1
fi
}
get_bmc_info() {
for host in $(jq -r -c '.[]' ${HOSTS_FILE}); do
if [ "$(echo "$host" | jq -r '.name')" = "$1" ]; then
addr=$(echo "$host" | jq -r '.bmc.ip')
user=$(echo "$host" | jq -r '.bmc.username')
pass=$(echo "$host" | jq -r '.bmc.password')
fi
done
}
reboot() {
get_bmc_info "$1"
if [ "${addr}" = "" ] || [ "${user}" = "" ] || [ "$pass" = "" ]; then
echo "Invalid host '$1'. Use the '${LIST_COMMAND}' command to view hosts."
exit 1
fi
echo "Rebooting host '$1'"
redfishtool -r "${addr}" -u "${user}" -p "${pass}" \
Systems reset GracefulRestart -vvvvv
exit 0
}
case $1 in
"${LIST_COMMAND}")
dep_check
jq -r '.[].name' ${HOSTS_FILE}
;;
"${REBOOT_COMMAND}")
if [ "$2" = "" ]; then
printf "Host name required.\n\n"
help
exit 1
fi
dep_check
reboot "$2"
;;
*)
help
;;
esac
`
/*
The SIP Cluster operator will manufacture a jump host pod specifically for this

View File

@ -2,6 +2,7 @@ package services_test
import (
"context"
"encoding/json"
airshipv1 "sipcluster/pkg/api/v1"
@ -25,6 +26,18 @@ const (
var bmh1 *metal3.BareMetalHost
var bmh2 *metal3.BareMetalHost
// Re-declared from services package for testing purposes
type host struct {
Name string `json:"name"`
BMC bmc `json:"bmc"`
}
type bmc struct {
IP string `json:"ip"`
Username string `json:"username"`
Password string `json:"password"`
}
var _ = Describe("Service Set", func() {
Context("When new SIP cluster is created", func() {
It("Deploys services", func() {
@ -32,6 +45,16 @@ var _ = Describe("Service Set", func() {
bmh1, _ = testutil.CreateBMH(1, "default", "control-plane", 1)
bmh2, _ = testutil.CreateBMH(2, "default", "control-plane", 2)
bmcUsername := "root"
bmcPassword := "password"
bmcSecret := testutil.CreateBMCAuthSecret(bmh1.GetName(), bmh1.GetNamespace(), bmcUsername,
bmcPassword)
Expect(k8sClient.Create(context.Background(), bmcSecret)).Should(Succeed())
bmh1.Spec.BMC.CredentialsName = bmcSecret.Name
bmh2.Spec.BMC.CredentialsName = bmcSecret.Name
m1 := &vbmh.Machine{
BMH: *bmh1,
Data: &vbmh.MachineData{
@ -40,6 +63,7 @@ var _ = Describe("Service Set", func() {
},
},
}
m2 := &vbmh.Machine{
BMH: *bmh2,
Data: &vbmh.MachineData{
@ -68,13 +92,13 @@ var _ = Describe("Service Set", func() {
}
Eventually(func() error {
return testDeployment(sip)
return testDeployment(sip, *machineList)
}, 5, 1).Should(Succeed())
})
})
})
func testDeployment(sip *airshipv1.SIPCluster) error {
func testDeployment(sip *airshipv1.SIPCluster, machineList vbmh.MachineList) error {
loadBalancerDeployment := &appsv1.Deployment{}
err := k8sClient.Get(context.Background(), types.NamespacedName{
Namespace: "default",
@ -110,6 +134,7 @@ func testDeployment(sip *airshipv1.SIPCluster) error {
if err != nil {
return err
}
jumpHostHostAliases := jumpHostDeployment.Spec.Template.Spec.HostAliases
Expect(jumpHostHostAliases).To(ConsistOf(
corev1.HostAlias{
@ -131,5 +156,26 @@ func testDeployment(sip *airshipv1.SIPCluster) error {
return err
}
jumpHostSecret := &corev1.Secret{}
err = k8sClient.Get(context.Background(), types.NamespacedName{
Namespace: "default",
Name: services.JumpHostServiceName + "-" + sip.GetName(),
}, jumpHostSecret)
if err != nil {
return err
}
var hosts []host
err = json.Unmarshal(jumpHostSecret.Data["hosts"], &hosts)
Expect(err).To(BeNil())
for _, host := range hosts {
for _, machine := range machineList.Machines {
if host.Name == machine.BMH.Name {
Expect(host.BMC.Username).To(Equal(machine.Data.BMCUsername))
Expect(host.BMC.Password).To(Equal(machine.Data.BMCPassword))
}
}
}
return nil
}

View File

@ -190,6 +190,9 @@ func CreateBMH(node int, namespace string, role airshipv1.VMRole, rack int) (*me
Namespace: namespace,
Name: networkDataName,
},
BMC: metal3.BMCDetails{
Address: "redfish+https://32.68.51.12/redfish/v1/Systems/System.Embedded.1",
},
},
}, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{