Skip to content
Merged
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: 14 additions & 0 deletions pkg/tnf/operator/starter.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"os"
"time"

configv1client "github.com/openshift/client-go/config/clientset/versioned"
configv1informersfactory "github.com/openshift/client-go/config/informers/externalversions"
configv1informers "github.com/openshift/client-go/config/informers/externalversions/config/v1"
operatorv1informers "github.com/openshift/client-go/operator/informers/externalversions/operator/v1"
"github.com/openshift/library-go/pkg/controller/controllercmd"
Expand Down Expand Up @@ -205,11 +207,23 @@ func runPacemakerControllers(ctx context.Context, controllerContext *controllerc
klog.Infof("starting Pacemaker metrics controller")
go metricsController.Run(ctx, 1)

// Create a config client for ClusterVersion access (console notification docs URL).
configClient, err := configv1client.NewForConfig(controllerContext.KubeConfig)
if err != nil {
klog.Errorf("failed to create config client: %v", err)
return
}
configInformers := configv1informersfactory.NewSharedInformerFactory(configClient, 10*time.Minute)
clusterVersionLister := configInformers.Config().V1().ClusterVersions().Lister()
configInformers.Start(ctx.Done())
configInformers.WaitForCacheSync(ctx.Done())

// Create and start the console notification controller, sharing the same informer
klog.Infof("creating Pacemaker console notification controller")
notificationController := pacemaker.NewConsoleNotificationController(
pacemakerInformer,
dynamicClient,
clusterVersionLister,
controllerContext.EventRecorder,
)
klog.Infof("starting Pacemaker console notification controller")
Expand Down
60 changes: 40 additions & 20 deletions pkg/tnf/pkg/pacemaker/consolenotification.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

consolev1 "github.com/openshift/api/console/v1"
pacmkrv1 "github.com/openshift/api/etcd/v1"
configv1listers "github.com/openshift/client-go/config/listers/config/v1"
"github.com/openshift/library-go/pkg/controller/factory"
"github.com/openshift/library-go/pkg/operator/events"
"github.com/openshift/library-go/pkg/operator/resource/resourceapply"
Expand All @@ -25,26 +26,26 @@ const (
notificationTextColor = "#fff"
notificationBackgroundColor = "#c9190b"

docsBasePath = "https://docs.redhat.com/en/documentation/openshift_container_platform/4.22/html/installing_a_two_node_openshift_cluster/two-node-with-fencing"
docsURLFormat = "https://docs.redhat.com/en/documentation/openshift_container_platform/%s/html/installing_a_two_node_openshift_cluster/two-node-with-fencing"
)

type notificationCategory struct {
name string
linkHref string
linkText string
name string
docsFragment string
linkText string
}

var (
categoryDegraded = notificationCategory{
name: "pacemaker-cluster-degraded",
linkHref: docsBasePath + "#operating-a-degraded-tnf",
linkText: "Recovery guide",
name: "pacemaker-cluster-degraded",
docsFragment: "#operating-a-degraded-tnf",
linkText: "Recovery guide",
}

categoryTroubleshooting = notificationCategory{
name: "pacemaker-troubleshooting",
linkHref: docsBasePath + "#installing-post-tnf",
linkText: "Troubleshooting guide",
name: "pacemaker-troubleshooting",
docsFragment: "#installing-post-tnf",
linkText: "Troubleshooting guide",
}

allCategories = []notificationCategory{categoryDegraded, categoryTroubleshooting}
Expand Down Expand Up @@ -83,21 +84,24 @@ func isDegradedProblem(msg string) bool {
}

type consoleNotificationController struct {
dynamicClient dynamic.Interface
recorder events.Recorder
pacemakerInformer cache.SharedIndexInformer
consoleUnavailable bool
dynamicClient dynamic.Interface
recorder events.Recorder
pacemakerInformer cache.SharedIndexInformer
clusterVersionLister configv1listers.ClusterVersionLister
consoleUnavailable bool
}

func NewConsoleNotificationController(
pacemakerInformer cache.SharedIndexInformer,
dynamicClient dynamic.Interface,
clusterVersionLister configv1listers.ClusterVersionLister,
eventRecorder events.Recorder,
) factory.Controller {
c := &consoleNotificationController{
dynamicClient: dynamicClient,
recorder: eventRecorder,
pacemakerInformer: pacemakerInformer,
dynamicClient: dynamicClient,
recorder: eventRecorder,
pacemakerInformer: pacemakerInformer,
clusterVersionLister: clusterVersionLister,
}

return factory.New().
Expand All @@ -107,6 +111,21 @@ func NewConsoleNotificationController(
ToController("ConsoleNotificationController", eventRecorder.WithComponentSuffix("console-notification"))
}

func (c *consoleNotificationController) docsBaseURL() string {
version := "latest"
cv, err := c.clusterVersionLister.Get("version")
if err != nil {
klog.V(4).Infof("Failed to get ClusterVersion for docs URL, using %q: %v", version, err)
return fmt.Sprintf(docsURLFormat, version)
}
if len(cv.Status.History) > 0 {
if parts := strings.SplitN(cv.Status.History[0].Version, ".", 3); len(parts) >= 2 {
version = parts[0] + "." + parts[1]
}
}
return fmt.Sprintf(docsURLFormat, version)
}

func (c *consoleNotificationController) sync(ctx context.Context, _ factory.SyncContext) error {
if c.consoleUnavailable {
return nil
Expand Down Expand Up @@ -147,8 +166,9 @@ func (c *consoleNotificationController) manageNotification(ctx context.Context,

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

u, err := buildNotificationUnstructured(cat, text)
u, err := buildNotificationUnstructured(cat, linkHref, text)
if err != nil {
return err
}
Expand Down Expand Up @@ -193,7 +213,7 @@ func (c *consoleNotificationController) filterConsoleError(err error) error {
return err
}

func buildNotificationUnstructured(cat notificationCategory, text string) (*unstructured.Unstructured, error) {
func buildNotificationUnstructured(cat notificationCategory, linkHref, text string) (*unstructured.Unstructured, error) {
notification := &consolev1.ConsoleNotification{
TypeMeta: metav1.TypeMeta{
APIVersion: "console.openshift.io/v1",
Expand All @@ -213,7 +233,7 @@ func buildNotificationUnstructured(cat notificationCategory, text string) (*unst
Color: notificationTextColor,
BackgroundColor: notificationBackgroundColor,
Link: &consolev1.Link{
Href: cat.linkHref,
Href: linkHref,
Text: cat.linkText,
},
},
Expand Down
105 changes: 86 additions & 19 deletions pkg/tnf/pkg/pacemaker/consolenotification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@ package pacemaker

import (
"context"
"fmt"
"slices"
"strings"
"testing"
"time"

configv1 "github.com/openshift/api/config/v1"
pacmkrv1 "github.com/openshift/api/etcd/v1"
configv1listers "github.com/openshift/client-go/config/listers/config/v1"
"github.com/openshift/cluster-etcd-operator/pkg/tnf/internal/testutil"
"github.com/openshift/library-go/pkg/operator/events"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
fakedynamic "k8s.io/client-go/dynamic/fake"
"k8s.io/client-go/tools/cache"
clocktesting "k8s.io/utils/clock/testing"
)

Expand Down Expand Up @@ -108,6 +112,22 @@ func testCluster(opts ...clusterOpt) *pacmkrv1.PacemakerCluster {
return cr
}

func fakeClusterVersionLister(t *testing.T, version string) configv1listers.ClusterVersionLister {
t.Helper()
indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{})
if version != "" {
require.NoError(t, indexer.Add(&configv1.ClusterVersion{
ObjectMeta: metav1.ObjectMeta{Name: "version"},
Status: configv1.ClusterVersionStatus{
History: []configv1.UpdateHistory{
{Version: version},
},
},
}))
}
return configv1listers.NewClusterVersionLister(indexer)
}

// ---------------------------------------------------------------------------
// BuildHealthStatusFromCR tests (validates shared health evaluation)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -287,14 +307,16 @@ func TestClassifyProblems(t *testing.T) {
// Sync-level tests
// ---------------------------------------------------------------------------

func newTestController(cr *pacmkrv1.PacemakerCluster) (*consoleNotificationController, *fakedynamic.FakeDynamicClient) {
func newTestController(t *testing.T, cr *pacmkrv1.PacemakerCluster) (*consoleNotificationController, *fakedynamic.FakeDynamicClient) {
t.Helper()
scheme := runtime.NewScheme()
dynClient := fakedynamic.NewSimpleDynamicClient(scheme)

return &consoleNotificationController{
dynamicClient: dynClient,
recorder: events.NewInMemoryRecorder("test", clocktesting.NewFakeClock(time.Now())),
pacemakerInformer: testutil.CreateFakeInformer(cr),
dynamicClient: dynClient,
recorder: events.NewInMemoryRecorder("test", clocktesting.NewFakeClock(time.Now())),
pacemakerInformer: testutil.CreateFakeInformer(cr),
clusterVersionLister: fakeClusterVersionLister(t, "4.22.0"),
}, dynClient
}

Expand Down Expand Up @@ -322,7 +344,7 @@ func countActions(dynClient *fakedynamic.FakeDynamicClient, verb string) int {

func TestSync_HealthyCluster_DeletesBothNotifications(t *testing.T) {
cr := testCluster(withNodes(testNode("master-0", "192.168.111.20"), testNode("master-1", "192.168.111.21")))
ctrl, dynClient := newTestController(cr)
ctrl, dynClient := newTestController(t, cr)

err := ctrl.sync(context.Background(), nil)
require.NoError(t, err)
Expand All @@ -334,7 +356,7 @@ func TestSync_NodeOffline_CreatesDegradedNotification(t *testing.T) {
testNode("master-0", "192.168.111.20", offline()),
testNode("master-1", "192.168.111.21"),
))
ctrl, dynClient := newTestController(cr)
ctrl, dynClient := newTestController(t, cr)

err := ctrl.sync(context.Background(), nil)
require.NoError(t, err)
Expand All @@ -343,7 +365,7 @@ func TestSync_NodeOffline_CreatesDegradedNotification(t *testing.T) {

func TestSync_StaleStatus_CreatesTroubleshootingNotification(t *testing.T) {
cr := testCluster(staleBy(10*time.Minute), withNodes(testNode("master-0", "192.168.111.20")))
ctrl, dynClient := newTestController(cr)
ctrl, dynClient := newTestController(t, cr)

err := ctrl.sync(context.Background(), nil)
require.NoError(t, err)
Expand All @@ -355,7 +377,7 @@ func TestSync_FencingDegraded_CreatesDegradedNotification(t *testing.T) {
testNode("master-0", "192.168.111.20", fencingDegraded()),
testNode("master-1", "192.168.111.21"),
))
ctrl, dynClient := newTestController(cr)
ctrl, dynClient := newTestController(t, cr)

err := ctrl.sync(context.Background(), nil)
require.NoError(t, err)
Expand All @@ -366,7 +388,7 @@ func TestSync_UninitializedCR_DeletesBothNotifications(t *testing.T) {
cr := &pacmkrv1.PacemakerCluster{
ObjectMeta: metav1.ObjectMeta{Name: PacemakerClusterResourceName},
}
ctrl, dynClient := newTestController(cr)
ctrl, dynClient := newTestController(t, cr)

err := ctrl.sync(context.Background(), nil)
require.NoError(t, err)
Expand All @@ -375,7 +397,7 @@ func TestSync_UninitializedCR_DeletesBothNotifications(t *testing.T) {
}

func TestSync_CRNotFound_DeletesBothNotifications(t *testing.T) {
ctrl, dynClient := newTestController(nil)
ctrl, dynClient := newTestController(t, nil)

err := ctrl.sync(context.Background(), nil)
require.NoError(t, err)
Expand All @@ -387,19 +409,59 @@ func TestSync_ConsoleUnavailable_SkipsSilently(t *testing.T) {
testNode("master-0", "192.168.111.20", unhealthyEtcd()),
testNode("master-1", "192.168.111.21"),
))
ctrl, _ := newTestController(cr)
ctrl, _ := newTestController(t, cr)
ctrl.consoleUnavailable = true

err := ctrl.sync(context.Background(), nil)
require.NoError(t, err)
}

// ---------------------------------------------------------------------------
// docsBaseURL tests
// ---------------------------------------------------------------------------

func TestDocsBaseURL(t *testing.T) {
tests := []struct {
name string
version string
wantContain string
}{
{
name: "extracts major.minor from full version",
version: "4.23.0",
wantContain: "/openshift_container_platform/4.23/",
},
{
name: "extracts major.minor from nightly version",
version: "4.24.0-0.nightly-2026-08-10-123456",
wantContain: "/openshift_container_platform/4.24/",
},
{
name: "falls back to latest when ClusterVersion unavailable",
version: "",
wantContain: "/openshift_container_platform/latest/",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := &consoleNotificationController{
clusterVersionLister: fakeClusterVersionLister(t, tt.version),
}
url := ctrl.docsBaseURL()
require.Contains(t, url, tt.wantContain)
require.Contains(t, url, "two-node-with-fencing")
})
}
}

// ---------------------------------------------------------------------------
// buildNotificationUnstructured tests
// ---------------------------------------------------------------------------

func TestBuildNotificationUnstructured_DegradedCategory(t *testing.T) {
u, err := buildNotificationUnstructured(categoryDegraded, "Node master-0 is offline. Check pacemaker status for details.")
linkHref := fmt.Sprintf(docsURLFormat, "4.22") + categoryDegraded.docsFragment
u, err := buildNotificationUnstructured(categoryDegraded, linkHref, "Node master-0 is offline. Check pacemaker status for details.")
require.NoError(t, err)
require.Equal(t, categoryDegraded.name, u.GetName())

Expand All @@ -410,20 +472,25 @@ func TestBuildNotificationUnstructured_DegradedCategory(t *testing.T) {
require.Equal(t, notificationBackgroundColor, bg)

href, _, _ := unstructured.NestedString(u.Object, "spec", "link", "href")
require.Equal(t, categoryDegraded.linkHref, href)
require.Equal(t, linkHref, href)
require.Contains(t, href, "/4.22/")
require.Contains(t, href, "#operating-a-degraded-tnf")

linkText, _, _ := unstructured.NestedString(u.Object, "spec", "link", "text")
require.Equal(t, categoryDegraded.linkText, linkText)
lt, _, _ := unstructured.NestedString(u.Object, "spec", "link", "text")
require.Equal(t, categoryDegraded.linkText, lt)
}

func TestBuildNotificationUnstructured_TroubleshootingCategory(t *testing.T) {
u, err := buildNotificationUnstructured(categoryTroubleshooting, "Pacemaker status is stale. Check pacemaker status for details.")
linkHref := fmt.Sprintf(docsURLFormat, "4.23") + categoryTroubleshooting.docsFragment
u, err := buildNotificationUnstructured(categoryTroubleshooting, linkHref, "Pacemaker status is stale. Check pacemaker status for details.")
require.NoError(t, err)
require.Equal(t, categoryTroubleshooting.name, u.GetName())

href, _, _ := unstructured.NestedString(u.Object, "spec", "link", "href")
require.Equal(t, categoryTroubleshooting.linkHref, href)
require.Equal(t, linkHref, href)
require.Contains(t, href, "/4.23/")
require.Contains(t, href, "#installing-post-tnf")

linkText, _, _ := unstructured.NestedString(u.Object, "spec", "link", "text")
require.Equal(t, categoryTroubleshooting.linkText, linkText)
lt, _, _ := unstructured.NestedString(u.Object, "spec", "link", "text")
require.Equal(t, categoryTroubleshooting.linkText, lt)
}