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
5 changes: 3 additions & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,9 @@ func main() {
os.Exit(1)
}
if err := (&controller.ClusterInstanceReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
APIReader: mgr.GetAPIReader(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "ClusterInstance")
os.Exit(1)
Expand Down
26 changes: 26 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ rules:
- patch
- update
- watch
- apiGroups:
- apiextensions.k8s.io
resources:
- customresourcedefinitions
verbs:
- get
- apiGroups:
- apps
resources:
- deployments
verbs:
- get
- apiGroups:
- batch
resources:
Expand Down Expand Up @@ -109,6 +121,13 @@ rules:
- get
- patch
- update
- apiGroups:
- hco.kubevirt.io
resources:
- hyperconvergeds
verbs:
- get
- list
- apiGroups:
- hypershift.openshift.io
resources:
Expand Down Expand Up @@ -142,6 +161,13 @@ rules:
- patch
- update
- watch
- apiGroups:
- multicluster.openshift.io
resources:
- multiclusterengines
verbs:
- get
- list
- apiGroups:
- route.openshift.io
resources:
Expand Down
76 changes: 75 additions & 1 deletion internal/controller/clusterinstance_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"time"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -58,7 +59,8 @@ const (
// ClusterInstanceReconciler reconciles a ClusterInstance object
type ClusterInstanceReconciler struct {
client.Client
Scheme *runtime.Scheme
Scheme *runtime.Scheme
APIReader client.Reader
}

// +kubebuilder:rbac:groups=guestcluster.opdev.io,resources=clusterinstances,verbs=get;list;watch;create;update;patch;delete
Expand All @@ -80,6 +82,10 @@ type ClusterInstanceReconciler struct {
// +kubebuilder:rbac:groups=route.openshift.io,resources=routes,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=route.openshift.io,resources=routes/custom-host,verbs=create;update
// +kubebuilder:rbac:groups=config.openshift.io,resources=ingresses,verbs=get;list;watch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get
// +kubebuilder:rbac:groups=hco.kubevirt.io,resources=hyperconvergeds,verbs=get;list
// +kubebuilder:rbac:groups=multicluster.openshift.io,resources=multiclusterengines,verbs=get;list
// +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get

// Reconcile drives a ClusterInstance through Provisioning -> Ready. It
// delegates the actual creation and inspection of backing objects (KubeVirt
Expand Down Expand Up @@ -142,6 +148,17 @@ func (r *ClusterInstanceReconciler) Reconcile(ctx context.Context, req ctrl.Requ
return ctrl.Result{}, nil
}

if instance.Status.Phase != brokerv1alpha1.PhaseFailed &&
(instance.Spec.Type == brokerv1alpha1.TopologyCRC ||
(instance.Spec.Type == brokerv1alpha1.TopologyHCP && instance.Status.Phase != brokerv1alpha1.PhaseReady)) {
if result, err := r.gatePlatformOperations(ctx, instance); result != nil || err != nil {
if result == nil {
return ctrl.Result{}, err
}
return *result, err
}
}

if instance.Status.Phase == brokerv1alpha1.PhaseReady {
if instance.Spec.Type == brokerv1alpha1.TopologyCRC {
return r.reconcileReadyCRC(ctx, instance)
Expand All @@ -159,6 +176,63 @@ func (r *ClusterInstanceReconciler) Reconcile(ctx context.Context, req ctrl.Requ
}
}

func (r *ClusterInstanceReconciler) gatePlatformOperations(ctx context.Context, instance *brokerv1alpha1.ClusterInstance) (*ctrl.Result, error) {
previousStatus := instance.Status.DeepCopy()
conditions := []platformCondition{r.checkHyperConverged(ctx)}
if instance.Spec.Type == brokerv1alpha1.TopologyHCP {
conditions = append(conditions, r.checkMultiClusterEngine(ctx))
}

ready := true
for _, condition := range conditions {
condition.condition.ObservedGeneration = instance.Generation
apimeta.SetStatusCondition(&instance.Status.Conditions, condition.condition)
ready = ready && condition.ready
}
instance.Status.ObservedGeneration = instance.Generation
if !ready {
instance.Status.Phase = brokerv1alpha1.PhaseProvisioning
apimeta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
Type: conditionTypeReady,
Status: metav1.ConditionFalse,
Reason: firstBlockedReason(conditions),
Message: "Platform dependencies are not ready",
ObservedGeneration: instance.Generation,
})
if err := r.updatePlatformStatus(ctx, instance, previousStatus); err != nil {
return nil, err
}
return &ctrl.Result{RequeueAfter: requeueInterval}, nil
}

if err := r.updatePlatformStatus(ctx, instance, previousStatus); err != nil {
return nil, err
}
if !equality.Semantic.DeepEqual(*previousStatus, instance.Status) {
return &ctrl.Result{}, nil
}
return nil, nil
}

func firstBlockedReason(conditions []platformCondition) string {
for _, condition := range conditions {
if !condition.ready {
return condition.condition.Reason
}
}
return "OperandNotReady"
}

func (r *ClusterInstanceReconciler) updatePlatformStatus(ctx context.Context, instance *brokerv1alpha1.ClusterInstance, previousStatus *brokerv1alpha1.ClusterInstanceStatus) error {
if equality.Semantic.DeepEqual(*previousStatus, instance.Status) {
return nil
}
if err := r.Status().Update(ctx, instance); err != nil {
return fmt.Errorf("updating platform readiness status: %w", err)
}
return nil
}

// reconcileLeaseRefProjection maintains Status.LeaseRef as a read-only,
// derived mirror of whichever ClusterLease (if any) currently names this
// instance via its own Status.InstanceRef, the single source of truth for
Expand Down
169 changes: 169 additions & 0 deletions internal/controller/platform_readiness.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
Copyright 2026.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package controller

import (
"context"
"fmt"

appsv1 "k8s.io/api/apps/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
)

const (
conditionTypeHyperConvergedReady = "HyperConvergedReady"
conditionTypeMultiClusterEngineReady = "MultiClusterEngineReady"
hyperShiftGroup = "hypershift.openshift.io"
)

var (
hyperConvergedGVK = schema.GroupVersionKind{Group: "hco.kubevirt.io", Version: "v1beta1", Kind: "HyperConverged"}
multiClusterEngineGVK = schema.GroupVersionKind{Group: "multicluster.openshift.io", Version: "v1", Kind: "MultiClusterEngine"}
customResourceDefinitionGVK = schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}
)

type platformCondition struct {
condition metav1.Condition
ready bool
}

func (r *ClusterInstanceReconciler) platformReader() client.Reader {
if r.APIReader != nil {
return r.APIReader
}
return r.Client
}

func (r *ClusterInstanceReconciler) checkHyperConverged(ctx context.Context) platformCondition {
operands := &unstructured.UnstructuredList{}
operands.SetGroupVersionKind(hyperConvergedGVK.GroupVersion().WithKind("HyperConvergedList"))
if err := r.platformReader().List(ctx, operands); err != nil {
return unavailablePlatformCondition(conditionTypeHyperConvergedReady, "HyperConverged")
}
if len(operands.Items) != 1 {
return singletonPlatformCondition(conditionTypeHyperConvergedReady, "HyperConverged", len(operands.Items))
}

operand := &operands.Items[0]
if observedGeneration, found, _ := unstructured.NestedInt64(operand.Object, "status", "observedGeneration"); !found || observedGeneration < operand.GetGeneration() {
return notReadyPlatformCondition(conditionTypeHyperConvergedReady, "StatusStale", "HyperConverged status does not observe its current generation")
}
for _, expected := range []struct {
typeName string
status metav1.ConditionStatus
}{
{typeName: "Available", status: metav1.ConditionTrue},
{typeName: "Progressing", status: metav1.ConditionFalse},
{typeName: "Degraded", status: metav1.ConditionFalse},
} {
actual, found := conditionStatus(operand, expected.typeName)
if !found || actual != expected.status {
return notReadyPlatformCondition(conditionTypeHyperConvergedReady, "OperandNotReady", fmt.Sprintf("HyperConverged condition %s must be %s", expected.typeName, expected.status))
}
}

return readyPlatformCondition(conditionTypeHyperConvergedReady, "HyperConverged is available")
}

func (r *ClusterInstanceReconciler) checkMultiClusterEngine(ctx context.Context) platformCondition {
operands := &unstructured.UnstructuredList{}
operands.SetGroupVersionKind(multiClusterEngineGVK.GroupVersion().WithKind("MultiClusterEngineList"))
if err := r.platformReader().List(ctx, operands); err != nil {
return unavailablePlatformCondition(conditionTypeMultiClusterEngineReady, "MultiClusterEngine")
}
if len(operands.Items) != 1 {
return singletonPlatformCondition(conditionTypeMultiClusterEngineReady, "MultiClusterEngine", len(operands.Items))
}
if !hyperShiftComponentEnabled(&operands.Items[0]) {
return notReadyPlatformCondition(conditionTypeMultiClusterEngineReady, "ComponentDisabled", "MultiClusterEngine does not enable the hypershift component")
}

for _, name := range []string{"hostedclusters." + hyperShiftGroup, "nodepools." + hyperShiftGroup} {
crd := &unstructured.Unstructured{}
crd.SetGroupVersionKind(customResourceDefinitionGVK)
if err := r.platformReader().Get(ctx, client.ObjectKey{Name: name}, crd); err != nil {
return unavailablePlatformCondition(conditionTypeMultiClusterEngineReady, name)
}
if status, found := conditionStatus(crd, "Established"); !found || status != metav1.ConditionTrue {
return notReadyPlatformCondition(conditionTypeMultiClusterEngineReady, "OperandNotReady", fmt.Sprintf("CustomResourceDefinition %s is not established", name))
}
}

operator := &appsv1.Deployment{}
if err := r.platformReader().Get(ctx, client.ObjectKey{Namespace: "hypershift", Name: "operator"}, operator); err != nil {
return unavailablePlatformCondition(conditionTypeMultiClusterEngineReady, "hypershift/operator Deployment")
}
if operator.Status.AvailableReplicas < 1 {
return notReadyPlatformCondition(conditionTypeMultiClusterEngineReady, "OperandNotReady", "Deployment hypershift/operator has no available replicas")
}

return readyPlatformCondition(conditionTypeMultiClusterEngineReady, "HyperShift APIs and operator are available")
}

func conditionStatus(obj *unstructured.Unstructured, typeName string) (metav1.ConditionStatus, bool) {
conditions, found, _ := unstructured.NestedSlice(obj.Object, "status", "conditions")
if !found {
return "", false
}
for _, item := range conditions {
condition, ok := item.(map[string]interface{})
if !ok || condition["type"] != typeName {
continue
}
status, ok := condition["status"].(string)
return metav1.ConditionStatus(status), ok
}
return "", false
}

func hyperShiftComponentEnabled(mce *unstructured.Unstructured) bool {
components, found, _ := unstructured.NestedSlice(mce.Object, "spec", "overrides", "components")
if !found {
return false
}
for _, item := range components {
component, ok := item.(map[string]interface{})
if !ok || component["name"] != "hypershift" {
continue
}
enabled, found, _ := unstructured.NestedBool(component, "enabled")
return !found || enabled
}
return false
}

func readyPlatformCondition(conditionType, message string) platformCondition {
return platformCondition{ready: true, condition: metav1.Condition{Type: conditionType, Status: metav1.ConditionTrue, Reason: "OperandReady", Message: message}}
}

func singletonPlatformCondition(conditionType, operand string, count int) platformCondition {
if count == 0 {
return notReadyPlatformCondition(conditionType, "OperandNotFound", fmt.Sprintf("no %s operand was found", operand))
}
return notReadyPlatformCondition(conditionType, "MultipleOperandsFound", fmt.Sprintf("found %d %s operands; exactly one is required", count, operand))
}

func unavailablePlatformCondition(conditionType, operand string) platformCondition {
return notReadyPlatformCondition(conditionType, "APIUnavailable", fmt.Sprintf("cannot read %s", operand))
}

func notReadyPlatformCondition(conditionType, reason, message string) platformCondition {
return platformCondition{condition: metav1.Condition{Type: conditionType, Status: metav1.ConditionFalse, Reason: reason, Message: message}}
}
Loading