Skip to content
Open
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
14 changes: 12 additions & 2 deletions pkg/tnf/operator/starter.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func HandleDualReplicaClusters(
if err := runTnfResourceController(ctx, controllerContext, kubeClient, dynamicClient, operatorClient, kubeInformersForNamespaces); err != nil {
return false, err
}
runPacemakerControllers(ctx, controllerContext, operatorClient, kubeClient, etcdInformer)
runPacemakerControllers(ctx, controllerContext, operatorClient, kubeClient, dynamicClient, etcdInformer)

// we need node names for assigning auth and after-setup jobs to specific nodes
controlPlaneNodeLister := corev1listers.NewNodeLister(controlPlaneNodeInformer.GetIndexer())
Expand Down Expand Up @@ -223,7 +223,7 @@ func runTnfResourceController(ctx context.Context, controllerContext *controller
return nil
}

func runPacemakerControllers(ctx context.Context, controllerContext *controllercmd.ControllerContext, operatorClient v1helpers.StaticPodOperatorClient, kubeClient kubernetes.Interface, etcdInformer operatorv1informers.EtcdInformer) {
func runPacemakerControllers(ctx context.Context, controllerContext *controllercmd.ControllerContext, operatorClient v1helpers.StaticPodOperatorClient, kubeClient kubernetes.Interface, dynamicClient dynamic.Interface, etcdInformer operatorv1informers.EtcdInformer) {
// Wait for external etcd transition before creating any Pacemaker controllers
// This runs in a background goroutine to avoid blocking the main thread.
go func() {
Expand Down Expand Up @@ -327,6 +327,16 @@ func runPacemakerControllers(ctx context.Context, controllerContext *controllerc
klog.Infof("starting Pacemaker metrics controller")
go metricsController.Run(ctx, 1)

// Create and start the console notification controller, sharing the same informer
klog.Infof("creating Pacemaker console notification controller")
notificationController := pacemaker.NewConsoleNotificationController(
pacemakerInformer,
dynamicClient,
controllerContext.EventRecorder,
)
klog.Infof("starting Pacemaker console notification controller")
go notificationController.Run(ctx, 1)

// Start the status collector CronJob
klog.Infof("starting Pacemaker status collector CronJob")
runPacemakerStatusCollectorCronJob(ctx, controllerContext, operatorClient, kubeClient)
Expand Down
210 changes: 210 additions & 0 deletions pkg/tnf/pkg/pacemaker/consolenotification.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package pacemaker

import (
"context"
"fmt"
"strings"
"time"

consolev1 "github.com/openshift/api/console/v1"
pacmkrv1 "github.com/openshift/api/etcd/v1"
"github.com/openshift/library-go/pkg/controller/factory"
"github.com/openshift/library-go/pkg/operator/events"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/tools/cache"
"k8s.io/klog/v2"
)

const (
consoleNotificationName = "pacemaker-etcd-failure"

// PatternFly danger palette for the banner.
notificationTextColor = "#fff"
notificationBackgroundColor = "#c9190b"

troubleshootingURL = "https://docs.openshift.com/container-platform/latest/edge_computing/two-node-fencing/tnf-troubleshooting.html"
troubleshootingText = "Troubleshooting guide"
)

var consoleNotificationGVR = schema.GroupVersionResource{
Group: "console.openshift.io",
Version: "v1",
Resource: "consolenotifications",
}

// consoleNotificationController manages a ConsoleNotification banner that surfaces
// pacemaker health problems directly in the OpenShift web console. It watches the
// PacemakerCluster CR (via a shared informer) and creates or removes the banner
// based on the health of etcd resources and fencing agents.
//
// Uses the dynamic client because the console client-go package is not vendored.
type consoleNotificationController struct {
dynamicClient dynamic.Interface
pacemakerInformer cache.SharedIndexInformer
}

// NewConsoleNotificationController creates a controller that manages a ConsoleNotification
// banner for pacemaker podman-etcd failures. The controller shares the PacemakerCluster
// informer with the healthcheck and metrics controllers.
func NewConsoleNotificationController(
pacemakerInformer cache.SharedIndexInformer,
dynamicClient dynamic.Interface,
eventRecorder events.Recorder,
) factory.Controller {
c := &consoleNotificationController{
dynamicClient: dynamicClient,
pacemakerInformer: pacemakerInformer,
}

return factory.New().
ResyncEvery(HealthCheckResyncInterval).
WithInformers(pacemakerInformer).
WithSync(c.sync).
ToController("ConsoleNotificationController", eventRecorder.WithComponentSuffix("console-notification"))
}

func (c *consoleNotificationController) sync(ctx context.Context, _ factory.SyncContext) error {
klog.V(4).Infof("syncing console notification for pacemaker health")

item, exists, err := c.pacemakerInformer.GetStore().GetByKey(PacemakerClusterResourceName)
if err != nil {
return err
}

if !exists {
return c.deleteNotification(ctx)
}

cr, ok := item.(*pacmkrv1.PacemakerCluster)
if !ok {
return fmt.Errorf("unexpected object type in informer store: %T", item)
}

problems := evaluateHealth(cr)
if len(problems) == 0 {
return c.deleteNotification(ctx)
}

return c.ensureNotification(ctx, problems)
}

// evaluateHealth inspects the PacemakerCluster CR for conditions that warrant a
// console notification. Returns a list of human-readable problem descriptions,
// or nil when the cluster is healthy.
func evaluateHealth(cr *pacmkrv1.PacemakerCluster) []string {
var problems []string

if !cr.Status.LastUpdated.IsZero() && time.Since(cr.Status.LastUpdated.Time) > StatusStalenessThreshold {
problems = append(problems, "Pacemaker status has not been updated recently — health monitoring may be offline")
}

if getConditionStatus(cr.Status.Conditions, pacmkrv1.ClusterInServiceConditionType) == metav1.ConditionFalse {
problems = append(problems, "Pacemaker cluster is in maintenance mode")
}

if cr.Status.Nodes == nil {
return problems
}
Comment thread
lucaconsalvi marked this conversation as resolved.

for i := range *cr.Status.Nodes {
node := &(*cr.Status.Nodes)[i]
name := node.NodeName

if getConditionStatus(node.Conditions, pacmkrv1.NodeOnlineConditionType) == metav1.ConditionFalse {
problems = append(problems, fmt.Sprintf("Node %s is offline", name))
continue
}

for j := range node.Resources {
res := &node.Resources[j]
if res.Name != pacmkrv1.PacemakerClusterResourceNameEtcd {
continue
}
if getConditionStatus(res.Conditions, pacmkrv1.ResourceHealthyConditionType) == metav1.ConditionFalse {
problems = append(problems, fmt.Sprintf("Etcd resource is unhealthy on node %s", name))
}
}

if getConditionStatus(node.Conditions, pacmkrv1.NodeFencingAvailableConditionType) == metav1.ConditionFalse {
problems = append(problems, fmt.Sprintf("Fencing is unavailable on node %s — automatic recovery is not possible", name))
}
}

return problems
}

func (c *consoleNotificationController) ensureNotification(ctx context.Context, problems []string) error {
text := strings.Join(problems, ". ") + ". Check pacemaker status for details."

notification := &consolev1.ConsoleNotification{
TypeMeta: metav1.TypeMeta{
APIVersion: "console.openshift.io/v1",
Kind: "ConsoleNotification",
},
ObjectMeta: metav1.ObjectMeta{
Name: consoleNotificationName,
Labels: map[string]string{
"app": "cluster-etcd-operator",
"app.kubernetes.io/part-of": "cluster-etcd-operator",
"app.kubernetes.io/managed-by": "cluster-etcd-operator",
},
},
Spec: consolev1.ConsoleNotificationSpec{
Text: text,
Location: consolev1.BannerTop,
Color: notificationTextColor,
BackgroundColor: notificationBackgroundColor,
Link: &consolev1.Link{
Href: troubleshootingURL,
Text: troubleshootingText,
},
},
}

obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(notification)
if err != nil {
return fmt.Errorf("failed to convert ConsoleNotification to unstructured: %w", err)
}
u := &unstructured.Unstructured{Object: obj}

existing, err := c.dynamicClient.Resource(consoleNotificationGVR).Get(ctx, consoleNotificationName, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
if _, err := c.dynamicClient.Resource(consoleNotificationGVR).Create(ctx, u, metav1.CreateOptions{}); err != nil {
return fmt.Errorf("failed to create ConsoleNotification: %w", err)
}
klog.Infof("Created console notification: %s", text)
Comment thread
lucaconsalvi marked this conversation as resolved.
return nil
}
if err != nil {
return fmt.Errorf("failed to get ConsoleNotification: %w", err)
}

existingText, _, _ := unstructured.NestedString(existing.Object, "spec", "text")
if existingText == text {
klog.V(4).Infof("Console notification already up to date")
return nil
}

u.SetResourceVersion(existing.GetResourceVersion())
if _, err := c.dynamicClient.Resource(consoleNotificationGVR).Update(ctx, u, metav1.UpdateOptions{}); err != nil {
return fmt.Errorf("failed to update ConsoleNotification: %w", err)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
klog.Infof("Updated console notification: %s", text)
return nil
}

func (c *consoleNotificationController) deleteNotification(ctx context.Context) error {
err := c.dynamicClient.Resource(consoleNotificationGVR).Delete(ctx, consoleNotificationName, metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("failed to delete ConsoleNotification: %w", err)
}
if err == nil {
klog.Infof("Deleted console notification: pacemaker health restored")
}
return nil
}
Loading