Skip to content

Commit 80f744b

Browse files
committed
e2e: add fencing credentials update and validation tests for dual-replica etcd
Add an e2e test that rotates BMC fencing credentials via the Redfish API, runs update-fencing-credentials.sh with the new password, and validates that fencing and cluster recovery still work with the updated credentials. Asserts PacemakerCluster CR health at each stage (baseline, post-update, post-recovery) to ensure CEO's view stays consistent with the live pacemaker state. Introduces Redfish API helpers, PacemakerCluster CR helpers, and fencing credentials discovery in the apis package.
1 parent 428b9a0 commit 80f744b

4 files changed

Lines changed: 560 additions & 2 deletions

File tree

test/extended/edge_topologies/tnf_recovery.go

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
exutil "github.com/openshift/origin/test/extended/util"
2222
corev1 "k8s.io/api/core/v1"
2323
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
k8srand "k8s.io/apimachinery/pkg/util/rand"
2425
"k8s.io/kubernetes/test/e2e/framework"
2526
)
2627

@@ -414,6 +415,230 @@ var _ = g.Describe("[sig-etcd][apigroup:config.openshift.io][OCPFeatureGate:Dual
414415
memberPromotedVotingTimeout, utils.FiveSecondPollInterval)
415416
})
416417

418+
g.It("should update fencing credentials and validate fencing with updated credentials", func() {
419+
bmcNode := targetNode
420+
survivedNode := peerNode
421+
422+
g.By(fmt.Sprintf("Reading current fencing credentials for node %s", bmcNode.Name))
423+
creds, err := apis.FindFencingCredentialsByNodeName(oc, bmcNode.Name)
424+
o.Expect(err).ToNot(o.HaveOccurred(), "expected to find fencing credentials secret")
425+
framework.Logf("Found fencing credentials secret %s (address: %s, username: %s)",
426+
creds.SecretName, creds.Address, creds.Username)
427+
428+
g.By("Parsing Redfish address from fencing credentials")
429+
redfishHost, redfishPort, redfishPath, err := apis.ParseRedfishAddress(creds.Address)
430+
o.Expect(err).ToNot(o.HaveOccurred(), "expected to parse Redfish address")
431+
framework.Logf("Redfish endpoint: host=%s port=%s path=%s", redfishHost, redfishPort, redfishPath)
432+
433+
isSushy := apis.IsSushyEmulator(redfishPath)
434+
var hypervisorSSH *core.SSHConfig
435+
var hypervisorKnownHosts string
436+
if isSushy {
437+
if !exutil.HasHypervisorConfig() {
438+
g.Skip("sushy-tools detected but no hypervisor SSH config available")
439+
}
440+
sshCfg := exutil.GetHypervisorConfig()
441+
o.Expect(sshCfg).ToNot(o.BeNil(), "expected hypervisor config to parse")
442+
hypervisorSSH = &core.SSHConfig{
443+
IP: sshCfg.HypervisorIP,
444+
User: sshCfg.SSHUser,
445+
PrivateKeyPath: sshCfg.PrivateKeyPath,
446+
}
447+
var khErr error
448+
hypervisorKnownHosts, khErr = core.PrepareLocalKnownHostsFile(hypervisorSSH)
449+
o.Expect(khErr).ToNot(o.HaveOccurred(), "expected to prepare hypervisor known_hosts")
450+
framework.Logf("Using sushy-tools password change via hypervisor SSH (%s)", hypervisorSSH.IP)
451+
}
452+
453+
changeBMCPassword := func(currentPw, newPw string) error {
454+
if isSushy {
455+
return apis.ChangeSushyToolsPassword(creds.Username, newPw, hypervisorSSH, hypervisorKnownHosts)
456+
}
457+
return apis.ChangeBMCPasswordViaRedfish(oc, bmcNode.Name, redfishHost, redfishPort,
458+
creds.Username, currentPw, newPw)
459+
}
460+
461+
hasPacemakerCR := apis.IsPacemakerClusterAvailable(oc)
462+
if hasPacemakerCR {
463+
g.By("Verifying PacemakerCluster CR is healthy before credential change")
464+
pc, pcErr := apis.GetPacemakerCluster(oc)
465+
o.Expect(pcErr).ToNot(o.HaveOccurred(), "expected to get PacemakerCluster CR")
466+
o.Expect(apis.ExpectClusterHealthy(pc)).ToNot(o.HaveOccurred(), "expected PacemakerCluster to be healthy before credential change")
467+
o.Expect(apis.ExpectNodeFencingHealthy(pc, bmcNode.Name)).ToNot(o.HaveOccurred(),
468+
"expected fencing to be healthy for %s before credential change", bmcNode.Name)
469+
} else {
470+
framework.Logf("PacemakerCluster CRD not available, skipping CR health checks")
471+
}
472+
473+
sslInsecure := creds.CertificateVerification == "Disabled"
474+
originalPassword := creds.Password
475+
newPassword := k8srand.String(32)
476+
nodeIdentifier := strings.TrimPrefix(creds.SecretName, "fencing-credentials-")
477+
478+
scriptPath := "/etc/kubernetes/static-pod-resources/etcd-certs/configmaps/etcd-scripts/update-fencing-credentials.sh"
479+
bashCmd := scriptPath + ` --node "$1" --username "$2" --password "$3" --address "$4"`
480+
if sslInsecure {
481+
bashCmd += " --ssl-insecure"
482+
}
483+
484+
// On sushy-tools, a single htpasswd file serves all BMC endpoints, so changing
485+
// the password affects all nodes. Fetch the survived node's credentials so we
486+
// can update its stonith device and secret in lockstep.
487+
var survivedNodeCreds *apis.FencingCredentials
488+
var survivedNodeIdentifier string
489+
var survivedBashCmd string
490+
if isSushy {
491+
survivedNodeCreds, err = apis.FindFencingCredentialsByNodeName(oc, survivedNode.Name)
492+
o.Expect(err).ToNot(o.HaveOccurred(), "expected to find survived node fencing credentials")
493+
survivedNodeIdentifier = strings.TrimPrefix(survivedNodeCreds.SecretName, "fencing-credentials-")
494+
survivedBashCmd = scriptPath + ` --node "$1" --username "$2" --password "$3" --address "$4"`
495+
if survivedNodeCreds.CertificateVerification == "Disabled" {
496+
survivedBashCmd += " --ssl-insecure"
497+
}
498+
framework.Logf("sushy-tools: will also update survived node %s credentials (secret: %s)",
499+
survivedNode.Name, survivedNodeCreds.SecretName)
500+
}
501+
502+
g.DeferCleanup(func() {
503+
var cleanupFailed bool
504+
505+
framework.Logf("Restoring original BMC password")
506+
if restoreErr := changeBMCPassword(newPassword, originalPassword); restoreErr != nil {
507+
fmt.Fprintf(g.GinkgoWriter, "Warning: failed to restore BMC password: %v\n", restoreErr)
508+
cleanupFailed = true
509+
}
510+
511+
scriptPassword := originalPassword
512+
if cleanupFailed {
513+
scriptPassword = newPassword
514+
}
515+
516+
framework.Logf("Re-running update-fencing-credentials.sh with original credentials")
517+
output, restoreErr := exutil.DebugNodeRetryWithOptionsAndChroot(oc, bmcNode.Name, "openshift-etcd",
518+
"bash", "-c", bashCmd, "update-fencing-credentials",
519+
nodeIdentifier, creds.Username, scriptPassword, creds.Address)
520+
if restoreErr != nil {
521+
fmt.Fprintf(g.GinkgoWriter, "Warning: failed to restore fencing credentials via script: %v\noutput: %s\n",
522+
restoreErr, output)
523+
}
524+
525+
if isSushy {
526+
framework.Logf("Restoring survived node %s fencing credentials (sushy-tools shares credentials)", survivedNode.Name)
527+
survivedOutput, survivedErr := exutil.DebugNodeRetryWithOptionsAndChroot(oc, survivedNode.Name, "openshift-etcd",
528+
"bash", "-c", survivedBashCmd, "update-fencing-credentials",
529+
survivedNodeIdentifier, survivedNodeCreds.Username, scriptPassword, survivedNodeCreds.Address)
530+
if survivedErr != nil {
531+
fmt.Fprintf(g.GinkgoWriter, "Warning: failed to restore survived node fencing credentials: %v\noutput: %s\n",
532+
survivedErr, survivedOutput)
533+
}
534+
}
535+
})
536+
537+
g.By(fmt.Sprintf("Changing BMC password on %s", bmcNode.Name))
538+
err = changeBMCPassword(originalPassword, newPassword)
539+
o.Expect(err).ToNot(o.HaveOccurred(), "expected to change BMC password")
540+
541+
g.By(fmt.Sprintf("Validating new BMC credentials via fence_redfish on %s", bmcNode.Name))
542+
err = apis.ValidateBMCCredentials(oc, bmcNode.Name, redfishHost, redfishPort, redfishPath,
543+
creds.Username, newPassword, sslInsecure)
544+
o.Expect(err).ToNot(o.HaveOccurred(), "expected new BMC credentials to be valid")
545+
546+
g.By(fmt.Sprintf("Running update-fencing-credentials.sh on %s with new credentials", bmcNode.Name))
547+
output, err := exutil.DebugNodeRetryWithOptionsAndChroot(oc, bmcNode.Name, "openshift-etcd",
548+
"bash", "-c", bashCmd, "update-fencing-credentials",
549+
nodeIdentifier, creds.Username, newPassword, creds.Address)
550+
o.Expect(err).ToNot(o.HaveOccurred(), "expected update-fencing-credentials.sh to succeed")
551+
framework.Logf("update-fencing-credentials.sh output:\n%s", output)
552+
553+
if isSushy {
554+
g.By(fmt.Sprintf("Updating survived node %s fencing credentials (sushy-tools shares credentials)", survivedNode.Name))
555+
survivedOutput, survivedErr := exutil.DebugNodeRetryWithOptionsAndChroot(oc, survivedNode.Name, "openshift-etcd",
556+
"bash", "-c", survivedBashCmd, "update-fencing-credentials",
557+
survivedNodeIdentifier, survivedNodeCreds.Username, newPassword, survivedNodeCreds.Address)
558+
o.Expect(survivedErr).ToNot(o.HaveOccurred(),
559+
"expected update-fencing-credentials.sh for survived node to succeed")
560+
framework.Logf("update-fencing-credentials.sh output for survived node:\n%s", survivedOutput)
561+
}
562+
563+
g.By("Validating pacemaker health after credential update")
564+
ctx, cancel := context.WithTimeout(context.Background(), nodeIsHealthyTimeout)
565+
defer cancel()
566+
pcsOutput, err := services.PcsStatusViaDebug(ctx, oc, bmcNode.Name)
567+
o.Expect(err).ToNot(o.HaveOccurred(), "expected pcs status to succeed")
568+
failedActions := services.ExtractPcsFailedActions(pcsOutput)
569+
o.Expect(failedActions).To(o.BeEmpty(), "expected no failed pacemaker resource actions after credential update")
570+
571+
g.By("Ensuring etcd members remain healthy after fencing credentials update")
572+
o.Eventually(func() error {
573+
if err := helpers.EnsureHealthyMember(g.GinkgoT(), etcdClientFactory, survivedNode.Name); err != nil {
574+
return err
575+
}
576+
if err := helpers.EnsureHealthyMember(g.GinkgoT(), etcdClientFactory, bmcNode.Name); err != nil {
577+
return err
578+
}
579+
return nil
580+
}, nodeIsHealthyTimeout, utils.FiveSecondPollInterval).ShouldNot(o.HaveOccurred(),
581+
"etcd members should be healthy after fencing credentials update")
582+
583+
if hasPacemakerCR {
584+
g.By("Verifying PacemakerCluster CR remains healthy after credential update")
585+
o.Eventually(func() error {
586+
pc, pcErr := apis.GetPacemakerCluster(oc)
587+
if pcErr != nil {
588+
return pcErr
589+
}
590+
if pcErr = apis.ExpectClusterHealthy(pc); pcErr != nil {
591+
return pcErr
592+
}
593+
return apis.ExpectNodeFencingHealthy(pc, bmcNode.Name)
594+
}, nodeIsHealthyTimeout, utils.FiveSecondPollInterval).ShouldNot(o.HaveOccurred(),
595+
"expected PacemakerCluster to remain healthy after credential update")
596+
}
597+
598+
g.By(fmt.Sprintf("Triggering fencing-style network disruption between %s and %s", bmcNode.Name, survivedNode.Name))
599+
command, err := exutil.TriggerNetworkDisruption(oc.KubeClient(), &bmcNode, &survivedNode, networkDisruptionDuration)
600+
o.Expect(err).To(o.BeNil(), "Expected to disrupt network without errors")
601+
framework.Logf("network disruption command: %q", command)
602+
603+
g.By(fmt.Sprintf("Ensuring cluster recovery after network disruption (timeout: %v)", memberIsLeaderTimeout))
604+
leaderNode, learnerNode, learnerStarted := validateEtcdRecoveryStateWithoutAssumingLeader(oc, etcdClientFactory,
605+
&survivedNode, &bmcNode, memberIsLeaderTimeout, utils.FiveSecondPollInterval)
606+
607+
if learnerStarted {
608+
framework.Logf("Learner node %q already started as learner after disruption", learnerNode.Name)
609+
} else {
610+
g.By(fmt.Sprintf("Ensuring '%s' rejoins as learner (timeout: %v)", learnerNode.Name, memberRejoinedLearnerTimeout))
611+
validateEtcdRecoveryState(oc, etcdClientFactory,
612+
leaderNode,
613+
learnerNode, true, true,
614+
memberRejoinedLearnerTimeout, utils.FiveSecondPollInterval)
615+
}
616+
617+
g.By(fmt.Sprintf("Ensuring learner node '%s' is promoted back as voting member (timeout: %v)", learnerNode.Name, memberPromotedVotingTimeout))
618+
validateEtcdRecoveryState(oc, etcdClientFactory,
619+
leaderNode,
620+
learnerNode, true, false,
621+
memberPromotedVotingTimeout, utils.FiveSecondPollInterval)
622+
623+
if hasPacemakerCR {
624+
g.By("Verifying PacemakerCluster CR is healthy after full recovery")
625+
o.Eventually(func() error {
626+
pc, pcErr := apis.GetPacemakerCluster(oc)
627+
if pcErr != nil {
628+
return pcErr
629+
}
630+
if pcErr = apis.ExpectClusterHealthy(pc); pcErr != nil {
631+
return pcErr
632+
}
633+
if pcErr = apis.ExpectNodeFencingHealthy(pc, bmcNode.Name); pcErr != nil {
634+
return pcErr
635+
}
636+
return apis.ExpectNodeFencingHealthy(pc, survivedNode.Name)
637+
}, nodeIsHealthyTimeout, utils.FiveSecondPollInterval).ShouldNot(o.HaveOccurred(),
638+
"expected PacemakerCluster to be fully healthy after recovery")
639+
}
640+
})
641+
417642
g.It("should compute etcd revision bump and preserve backup container after kernel panic recovery", func() {
418643
// Note: This test triggers a kernel panic on one node via sysrq trigger, then verifies
419644
// the surviving node computes the etcd revision bump as floor(maxRaftIndex * 0.2) per

test/extended/edge_topologies/utils/apis/baremetalhost.go

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,72 @@ import (
2020
)
2121

2222
const (
23-
BMCSecretNamespace = "openshift-machine-api"
24-
secretsDataPasswordKey = "password"
23+
BMCSecretNamespace = "openshift-machine-api"
24+
FencingCredentialsNamespace = "openshift-etcd"
25+
fencingCredentialsPrefix = "fencing-credentials-"
26+
secretsDataPasswordKey = "password"
2527
)
2628

29+
// FencingCredentials holds the fields from a fencing-credentials secret in openshift-etcd.
30+
type FencingCredentials struct {
31+
SecretName string
32+
Address string
33+
Username string
34+
Password string
35+
CertificateVerification string
36+
}
37+
38+
// FindFencingCredentialsByNodeName discovers the fencing-credentials secret for a node
39+
// by listing secrets in openshift-etcd and matching against the node's short name.
40+
func FindFencingCredentialsByNodeName(oc *exutil.CLI, nodeName string) (*FencingCredentials, error) {
41+
shortName := strings.Split(nodeName, ".")[0]
42+
43+
ctx := context.Background()
44+
list, err := oc.AdminKubeClient().CoreV1().Secrets(FencingCredentialsNamespace).List(ctx, metav1.ListOptions{})
45+
if err != nil {
46+
return nil, fmt.Errorf("list secrets in %s: %w", FencingCredentialsNamespace, err)
47+
}
48+
49+
expected := map[string]struct{}{
50+
fencingCredentialsPrefix + shortName: {},
51+
fencingCredentialsPrefix + nodeName: {},
52+
}
53+
54+
for _, secret := range list.Items {
55+
if _, ok := expected[secret.Name]; ok {
56+
getRequired := func(key string) (string, error) {
57+
v, exists := secret.Data[key]
58+
if !exists || len(v) == 0 {
59+
return "", fmt.Errorf("secret %s missing required key %q", secret.Name, key)
60+
}
61+
return string(v), nil
62+
}
63+
address, err := getRequired("address")
64+
if err != nil {
65+
return nil, err
66+
}
67+
username, err := getRequired("username")
68+
if err != nil {
69+
return nil, err
70+
}
71+
password, err := getRequired("password")
72+
if err != nil {
73+
return nil, err
74+
}
75+
return &FencingCredentials{
76+
SecretName: secret.Name,
77+
Address: address,
78+
Username: username,
79+
Password: password,
80+
CertificateVerification: string(secret.Data["certificateVerification"]),
81+
}, nil
82+
}
83+
}
84+
85+
return nil, fmt.Errorf("no fencing-credentials secret found matching node %q (prefix: %s, contains: %s) in %s",
86+
nodeName, fencingCredentialsPrefix, shortName, FencingCredentialsNamespace)
87+
}
88+
2789
// BMHGVR is the GroupVersionResource for BareMetalHost (metal3.io/v1alpha1). Use for API-based get/delete/patch.
2890
var BMHGVR = schema.GroupVersionResource{
2991
Group: "metal3.io", Version: "v1alpha1", Resource: "baremetalhosts",
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package apis
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
etcdv1alpha1 "github.com/openshift/api/etcd/v1alpha1"
9+
exutil "github.com/openshift/origin/test/extended/util"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"k8s.io/apimachinery/pkg/runtime"
12+
"k8s.io/apimachinery/pkg/runtime/schema"
13+
)
14+
15+
var PacemakerClusterGVR = schema.GroupVersionResource{
16+
Group: etcdv1alpha1.GroupName, Version: "v1alpha1", Resource: "pacemakerclusters",
17+
}
18+
19+
func IsPacemakerClusterAvailable(oc *exutil.CLI) bool {
20+
_, err := oc.AdminDynamicClient().Resource(PacemakerClusterGVR).List(
21+
context.Background(), metav1.ListOptions{Limit: 1})
22+
return err == nil
23+
}
24+
25+
func GetPacemakerCluster(oc *exutil.CLI) (*etcdv1alpha1.PacemakerCluster, error) {
26+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
27+
defer cancel()
28+
u, err := oc.AdminDynamicClient().Resource(PacemakerClusterGVR).Get(ctx, "cluster", metav1.GetOptions{})
29+
if err != nil {
30+
return nil, fmt.Errorf("get PacemakerCluster: %w", err)
31+
}
32+
var pc etcdv1alpha1.PacemakerCluster
33+
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.UnstructuredContent(), &pc); err != nil {
34+
return nil, fmt.Errorf("convert PacemakerCluster: %w", err)
35+
}
36+
return &pc, nil
37+
}
38+
39+
func findCondition(conditions []metav1.Condition, condType string) *metav1.Condition {
40+
for i := range conditions {
41+
if conditions[i].Type == condType {
42+
return &conditions[i]
43+
}
44+
}
45+
return nil
46+
}
47+
48+
func ExpectClusterHealthy(pc *etcdv1alpha1.PacemakerCluster) error {
49+
c := findCondition(pc.Status.Conditions, etcdv1alpha1.ClusterHealthyConditionType)
50+
if c == nil {
51+
return fmt.Errorf("PacemakerCluster missing %s condition", etcdv1alpha1.ClusterHealthyConditionType)
52+
}
53+
if c.Status != metav1.ConditionTrue {
54+
return fmt.Errorf("PacemakerCluster %s=%s (reason: %s, message: %s)",
55+
etcdv1alpha1.ClusterHealthyConditionType, c.Status, c.Reason, c.Message)
56+
}
57+
return nil
58+
}
59+
60+
func ExpectNodeFencingHealthy(pc *etcdv1alpha1.PacemakerCluster, nodeName string) error {
61+
if pc.Status.Nodes == nil {
62+
return fmt.Errorf("PacemakerCluster has no nodes in status")
63+
}
64+
for _, node := range *pc.Status.Nodes {
65+
if node.NodeName != nodeName {
66+
continue
67+
}
68+
c := findCondition(node.Conditions, etcdv1alpha1.NodeFencingHealthyConditionType)
69+
if c == nil {
70+
return fmt.Errorf("node %s missing %s condition", nodeName, etcdv1alpha1.NodeFencingHealthyConditionType)
71+
}
72+
if c.Status != metav1.ConditionTrue {
73+
return fmt.Errorf("node %s %s=%s (reason: %s, message: %s)",
74+
nodeName, etcdv1alpha1.NodeFencingHealthyConditionType, c.Status, c.Reason, c.Message)
75+
}
76+
return nil
77+
}
78+
return fmt.Errorf("node %s not found in PacemakerCluster status", nodeName)
79+
}

0 commit comments

Comments
 (0)