diff --git a/api/hypershift/v1beta1/hostedcluster_conditions.go b/api/hypershift/v1beta1/hostedcluster_conditions.go index e9e01de0acc..a8e46558c9e 100644 --- a/api/hypershift/v1beta1/hostedcluster_conditions.go +++ b/api/hypershift/v1beta1/hostedcluster_conditions.go @@ -285,6 +285,7 @@ const ( ProxyCABundleInvalidReason = "ProxyCABundleInvalid" PlatformCredentialsNotFoundReason = "PlatformCredentialsNotFound" InvalidImageReason = "InvalidImage" + ReleaseImageValidationSkippedReason = "ReleaseImageValidationSkipped" InvalidIdentityProvider = "InvalidIdentityProvider" PayloadArchNotFoundReason = "PayloadArchNotFound" diff --git a/api/hypershift/v1beta1/hostedcluster_types.go b/api/hypershift/v1beta1/hostedcluster_types.go index c2d63498870..849ca5d9643 100644 --- a/api/hypershift/v1beta1/hostedcluster_types.go +++ b/api/hypershift/v1beta1/hostedcluster_types.go @@ -188,6 +188,10 @@ const ( // resource-request-override.hypershift.openshift.io/kube-apiserver.kube-apiserver: memory=3Gi,cpu=2000m ResourceRequestOverrideAnnotationPrefix = "resource-request-override.hypershift.openshift.io" + // OCMLabelPrefix is the label key prefix used by OCM to tag HostedCluster + // resources. Labels with this prefix are mirrored to the HostedControlPlane. + OCMLabelPrefix = "api.openshift.com/" + // LimitedSupportLabel is a label that can be used by consumers to indicate // a cluster is somehow out of regular support policy. // https://docs.openshift.com/rosa/rosa_architecture/rosa_policy_service_definition/rosa-service-definition.html#rosa-limited-support_rosa-service-definition. diff --git a/hypershift-operator/controllers/hostedcluster/hostedcluster_controller.go b/hypershift-operator/controllers/hostedcluster/hostedcluster_controller.go index 022b442d182..b13d16c38bc 100644 --- a/hypershift-operator/controllers/hostedcluster/hostedcluster_controller.go +++ b/hypershift-operator/controllers/hostedcluster/hostedcluster_controller.go @@ -30,7 +30,6 @@ import ( "time" hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" - hyperkarpenterv1 "github.com/openshift/hypershift/api/karpenter/v1" "github.com/openshift/hypershift/api/util/configrefs" "github.com/openshift/hypershift/cmd/util" "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/imageprovider" @@ -227,7 +226,7 @@ func (r *HostedClusterReconciler) SetupWithManager(mgr ctrl.Manager, createOrUpd // namespaces, the events are filtered to enqueue only those resources which // are annotated as being associated with a hostedcluster (using an annotation). bldr := ctrl.NewControllerManagedBy(mgr). - For(&hyperv1.HostedCluster{}, builder.WithPredicates(hyperutil.PredicatesForHostedClusterAnnotationScoping(mgr.GetClient()))). + For(&hyperv1.HostedCluster{}, builder.WithPredicates(hostedClusterPrimaryPredicate(mgr.GetClient()))). WithOptions(controller.Options{ RateLimiter: workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request](1*time.Second, 10*time.Second), MaxConcurrentReconciles: 10, @@ -1188,13 +1187,22 @@ func (r *HostedClusterReconciler) reconcile(ctx context.Context, req ctrl.Reques // This check can be expensive looking up release image versions // (hopefully they are cached). Skip if we have already observed for // this generation. - if condition == nil || condition.ObservedGeneration != hcluster.Generation || condition.Status != metav1.ConditionTrue { + if shouldValidateReleaseImage(hcluster, condition) { condition := metav1.Condition{ Type: string(hyperv1.ValidReleaseImage), ObservedGeneration: hcluster.Generation, } - err := r.validateReleaseImage(ctx, hcluster, releaseProvider) - if err != nil { + skipReleaseImageValidation := hasSkipReleaseImageValidationAnnotation(hcluster) + var err error + if !skipReleaseImageValidation { + err = r.validateReleaseImage(ctx, hcluster, releaseProvider) + } + switch { + case skipReleaseImageValidation: + condition.Status = metav1.ConditionTrue + condition.Message = "Release image validation is skipped by annotation" + condition.Reason = hyperv1.ReleaseImageValidationSkippedReason + case err != nil: condition.Status = metav1.ConditionFalse condition.Message = err.Error() @@ -1203,7 +1211,7 @@ func (r *HostedClusterReconciler) reconcile(ctx context.Context, req ctrl.Reques } else { condition.Reason = hyperv1.InvalidImageReason } - } else { + default: condition.Status = metav1.ConditionTrue condition.Message = "Release image is valid" condition.Reason = hyperv1.AsExpectedReason @@ -2318,57 +2326,7 @@ func reconcileHostedControlPlaneAnnotations(hcp *hyperv1.HostedControlPlane, hcl hcp.Annotations[k8sutil.HostedClusterAnnotation] = client.ObjectKeyFromObject(hcluster).String() // These annotations are copied from the HostedCluster - mirroredAnnotations := []string{ - hyperv1.DisablePKIReconciliationAnnotation, - hyperv1.OauthLoginURLOverrideAnnotation, - hyperv1.KonnectivityAgentImageAnnotation, - hyperv1.KonnectivityServerImageAnnotation, - hyperv1.ClusterAutoscalerImage, - hyperv1.IBMCloudKMSProviderImage, - hyperv1.AWSKMSProviderImage, - hyperv1.PortierisImageAnnotation, - k8sutil.DebugDeploymentsAnnotation, - hyperv1.DisableProfilingAnnotation, - hyperv1.PrivateIngressControllerAnnotation, - hyperv1.IngressControllerLoadBalancerScope, - hyperv1.CleanupCloudResourcesAnnotation, - hyperv1.ControlPlanePriorityClass, - hyperv1.APICriticalPriorityClass, - hyperv1.EtcdPriorityClass, - hyperv1.EnsureExistsPullSecretReconciliation, - hyperv1.TopologyAnnotation, - hyperv1.DisableMachineManagement, - hyperv1.CertifiedOperatorsCatalogImageAnnotation, - hyperv1.CommunityOperatorsCatalogImageAnnotation, - hyperv1.RedHatMarketplaceCatalogImageAnnotation, - hyperv1.RedHatOperatorsCatalogImageAnnotation, - hyperv1.OLMCatalogsISRegistryOverridesAnnotation, - hyperv1.KubeAPIServerGOGCAnnotation, - hyperv1.KubeAPIServerGOMemoryLimitAnnotation, - hyperv1.RequestServingNodeAdditionalSelectorAnnotation, - hyperv1.AWSLoadBalancerSubnetsAnnotation, - hyperv1.AWSLoadBalancerTargetNodesAnnotation, - hyperv1.AWSLoadBalancerHealthProbeModeAnnotation, - hyperv1.AzureLoadBalancerHealthProbeModeAnnotation, - hyperv1.SharedLoadBalancerHealthProbePathAnnotation, - hyperv1.SharedLoadBalancerHealthProbePortAnnotation, - hyperv1.ManagementPlatformAnnotation, - hyperv1.KubeAPIServerVerbosityLevelAnnotation, - hyperv1.KubeAPIServerMaximumRequestsInFlight, - hyperv1.KubeAPIServerMaximumMutatingRequestsInFlight, - hyperv1.DisableIgnitionServerAnnotation, - hyperv1.AWSMachinePublicIPs, - hyperv1.AWSKarpenterDefaultInstanceProfile, - hyperkarpenterv1.KarpenterProviderAWSImage, - hyperv1.KubeAPIServerGoAwayChance, - hyperv1.KubeAPIServerServiceAccountTokenMaxExpiration, - hyperv1.HostedClusterRestoredFromBackupAnnotation, - // TODO: Remove this once the the input is in the HostedCluster AWS API. - "hypershift.openshift.io/aws-termination-handler-queue-url", - hyperv1.SwiftPodNetworkInstanceAnnotation, - hyperv1.EnableMetricsForwarding, - } - for _, key := range mirroredAnnotations { + for _, key := range hostedClusterMirroredAnnotations { val, hasVal := hcluster.Annotations[key] if hasVal { hcp.Annotations[key] = val @@ -2377,22 +2335,17 @@ func reconcileHostedControlPlaneAnnotations(hcp *hyperv1.HostedControlPlane, hcl } } - prefixesToSync := []string{ - hyperv1.IdentityProviderOverridesAnnotationPrefix, - hyperv1.ResourceRequestOverrideAnnotationPrefix, - } - // All annotations on the HostedCluster with prefixes to sync // should be synced for key := range hcp.Annotations { - for _, prefix := range prefixesToSync { + for _, prefix := range hostedClusterActionableAnnotationPrefixes { if strings.HasPrefix(key, prefix) { delete(hcp.Annotations, key) } } } for key, val := range hcluster.Annotations { - for _, prefix := range prefixesToSync { + for _, prefix := range hostedClusterActionableAnnotationPrefixes { if strings.HasPrefix(key, prefix) { hcp.Annotations[key] = val } @@ -2445,9 +2398,20 @@ func reconcileHostedControlPlane(hcp *hyperv1.HostedControlPlane, hcluster *hype } // All labels on the HostedCluster with this special prefix are copied // Those are labels set by OCM + for key := range hcp.Labels { + for _, prefix := range hostedClusterActionableLabelPrefixes { + if strings.HasPrefix(key, prefix) { + delete(hcp.Labels, key) + break + } + } + } for key, val := range hcluster.Labels { - if strings.HasPrefix(key, "api.openshift.com") { - hcp.Labels[key] = val + for _, prefix := range hostedClusterActionableLabelPrefixes { + if strings.HasPrefix(key, prefix) { + hcp.Labels[key] = val + break + } } } @@ -3743,7 +3707,7 @@ func (r *HostedClusterReconciler) validateUserCAConfigMaps(ctx context.Context, } func (r *HostedClusterReconciler) validateReleaseImage(ctx context.Context, hc *hyperv1.HostedCluster, releaseProvider releaseinfo.ProviderWithOpenShiftImageRegistryOverrides) error { - if _, exists := hc.Annotations[hyperv1.SkipReleaseImageValidation]; exists { + if hasSkipReleaseImageValidationAnnotation(hc) { return nil } pullSecretBytes, err := hyperutil.GetPullSecretBytes(ctx, r.Client, hc) @@ -3778,6 +3742,24 @@ func (r *HostedClusterReconciler) validateReleaseImage(ctx context.Context, hc * return supportedversion.IsValidReleaseVersion(&version, currentVersion, &supportedversion.LatestSupportedVersion, &minSupportedVersion, hc.Spec.Networking.NetworkType, hc.Spec.Platform.Type) } +func shouldValidateReleaseImage(hcluster *hyperv1.HostedCluster, condition *metav1.Condition) bool { + if condition == nil || condition.ObservedGeneration != hcluster.Generation || condition.Status != metav1.ConditionTrue { + return true + } + + skipReleaseImageValidation := hasSkipReleaseImageValidationAnnotation(hcluster) + if skipReleaseImageValidation { + return condition.Reason != hyperv1.ReleaseImageValidationSkippedReason + } + + return condition.Reason == hyperv1.ReleaseImageValidationSkippedReason +} + +func hasSkipReleaseImageValidationAnnotation(hcluster *hyperv1.HostedCluster) bool { + _, exists := hcluster.Annotations[hyperv1.SkipReleaseImageValidation] + return exists +} + func isProgressing(hc *hyperv1.HostedCluster, releaseImage *releaseinfo.ReleaseImage, refWithDigest func() (string, error)) (bool, error) { for _, condition := range hc.Status.Conditions { switch string(condition.Type) { diff --git a/hypershift-operator/controllers/hostedcluster/hostedcluster_hcp_labels_test.go b/hypershift-operator/controllers/hostedcluster/hostedcluster_hcp_labels_test.go new file mode 100644 index 00000000000..ae83d9c9e95 --- /dev/null +++ b/hypershift-operator/controllers/hostedcluster/hostedcluster_hcp_labels_test.go @@ -0,0 +1,45 @@ +package hostedcluster + +import ( + "testing" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestReconcileHostedControlPlane_WhenActionableLabelsAreRemovedItShouldClearStaleHostedControlPlaneLabels(t *testing.T) { + t.Parallel() + + hcluster := &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", + Namespace: "clusters", + Labels: map[string]string{}, + }, + } + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", + Namespace: "clusters-example", + Labels: map[string]string{ + "api.openshift.com/id": "stale", + "other": "keep", + }, + }, + } + + if err := reconcileHostedControlPlane(hcp, hcluster, false, false, func() (map[string]string, error) { + return map[string]string{}, nil + }); err != nil { + t.Fatalf("reconcileHostedControlPlane returned error: %v", err) + } + + if _, exists := hcp.Labels["api.openshift.com/id"]; exists { + t.Fatal("expected stale api.openshift.com label to be removed from hosted control plane") + } + if value := hcp.Labels["other"]; value != "keep" { + t.Fatalf("expected unrelated label to be preserved, got %q", value) + } +} diff --git a/hypershift-operator/controllers/hostedcluster/hostedcluster_predicates.go b/hypershift-operator/controllers/hostedcluster/hostedcluster_predicates.go new file mode 100644 index 00000000000..c9a5084ead4 --- /dev/null +++ b/hypershift-operator/controllers/hostedcluster/hostedcluster_predicates.go @@ -0,0 +1,204 @@ +package hostedcluster + +import ( + "strings" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + hyperkarpenterv1 "github.com/openshift/hypershift/api/karpenter/v1" + "github.com/openshift/hypershift/support/k8sutil" + hyperutil "github.com/openshift/hypershift/support/util" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// hostedClusterMirroredAnnotations are the HostedCluster annotations consumed by reconciliation and mirrored to the HostedControlPlane. +// Keep this list in sync with reconcileHostedControlPlaneAnnotations. +var hostedClusterMirroredAnnotations = []string{ + hyperv1.DisablePKIReconciliationAnnotation, + hyperv1.OauthLoginURLOverrideAnnotation, + hyperv1.KonnectivityAgentImageAnnotation, + hyperv1.KonnectivityServerImageAnnotation, + hyperv1.ClusterAutoscalerImage, + hyperv1.IBMCloudKMSProviderImage, + hyperv1.AWSKMSProviderImage, + hyperv1.PortierisImageAnnotation, + k8sutil.DebugDeploymentsAnnotation, + hyperv1.DisableProfilingAnnotation, + hyperv1.PrivateIngressControllerAnnotation, + hyperv1.IngressControllerLoadBalancerScope, + hyperv1.CleanupCloudResourcesAnnotation, + hyperv1.ControlPlanePriorityClass, + hyperv1.APICriticalPriorityClass, + hyperv1.EtcdPriorityClass, + hyperv1.EnsureExistsPullSecretReconciliation, + hyperv1.TopologyAnnotation, + hyperv1.DisableMachineManagement, + hyperv1.CertifiedOperatorsCatalogImageAnnotation, + hyperv1.CommunityOperatorsCatalogImageAnnotation, + hyperv1.RedHatMarketplaceCatalogImageAnnotation, + hyperv1.RedHatOperatorsCatalogImageAnnotation, + hyperv1.OLMCatalogsISRegistryOverridesAnnotation, + hyperv1.KubeAPIServerGOGCAnnotation, + hyperv1.KubeAPIServerGOMemoryLimitAnnotation, + hyperv1.RequestServingNodeAdditionalSelectorAnnotation, + hyperv1.AWSLoadBalancerSubnetsAnnotation, + hyperv1.AWSLoadBalancerTargetNodesAnnotation, + hyperv1.AWSLoadBalancerHealthProbeModeAnnotation, + hyperv1.AzureLoadBalancerHealthProbeModeAnnotation, + hyperv1.SharedLoadBalancerHealthProbePathAnnotation, + hyperv1.SharedLoadBalancerHealthProbePortAnnotation, + hyperv1.ManagementPlatformAnnotation, + hyperv1.KubeAPIServerVerbosityLevelAnnotation, + hyperv1.KubeAPIServerMaximumRequestsInFlight, + hyperv1.KubeAPIServerMaximumMutatingRequestsInFlight, + hyperv1.DisableIgnitionServerAnnotation, + hyperv1.AWSMachinePublicIPs, + hyperv1.AWSKarpenterDefaultInstanceProfile, + hyperkarpenterv1.KarpenterProviderAWSImage, + hyperv1.KubeAPIServerGoAwayChance, + hyperv1.KubeAPIServerServiceAccountTokenMaxExpiration, + hyperv1.HostedClusterRestoredFromBackupAnnotation, + // TODO: Remove this once the input is in the HostedCluster AWS API. + "hypershift.openshift.io/aws-termination-handler-queue-url", + hyperv1.SwiftPodNetworkInstanceAnnotation, + hyperv1.EnableMetricsForwarding, +} + +// hostedClusterActionableAnnotationPrefixes are the HostedCluster annotation prefixes consumed by reconciliation and mirrored to the HostedControlPlane. +var hostedClusterActionableAnnotationPrefixes = []string{ + hyperv1.IdentityProviderOverridesAnnotationPrefix, + hyperv1.ResourceRequestOverrideAnnotationPrefix, +} + +var hostedClusterAdditionalActionableAnnotations = []string{ + hyperv1.RestartDateAnnotation, + hyperv1.ForceUpgradeToAnnotation, + hyperv1.AllowUnsupportedKubeVirtRHCOSVariantsAnnotation, + hyperv1.HCDestroyGracePeriodAnnotation, + hyperv1.PodSecurityAdmissionLabelOverrideAnnotation, + hyperv1.ClusterAPIManagerImage, + hyperv1.ClusterAPIProviderAWSImage, + hyperv1.ClusterAPIAzureProviderImage, + hyperv1.ClusterAPIGCPProviderImage, + hyperv1.ClusterAPIAgentProviderImage, + hyperv1.ClusterAPIKubeVirtProviderImage, + hyperv1.ClusterAPIPowerVSProviderImage, + hyperv1.ClusterAPIOpenStackProviderImage, + hyperv1.OpenStackResourceControllerImage, + hyperv1.SkipReleaseImageValidation, + hyperv1.SkipKASConflicSANValidation, + hyperv1.SkipControlPlaneNamespaceDeletionAnnotation, + k8sutil.HostedClustersScopeAnnotation, +} + +var hostedClusterActionableLabelPrefixes = []string{ + hyperv1.OCMLabelPrefix, +} + +func hostedClusterPrimaryPredicate(r client.Reader) predicate.Predicate { + return predicate.And( + hyperutil.PredicatesForHostedClusterAnnotationScoping(r), + predicate.Or( + predicate.GenerationChangedPredicate{}, + predicate.TypedFuncs[client.Object]{ + UpdateFunc: func(e event.TypedUpdateEvent[client.Object]) bool { + oldHC, ok := e.ObjectOld.(*hyperv1.HostedCluster) + if !ok || oldHC == nil { + return false + } + + newHC, ok := e.ObjectNew.(*hyperv1.HostedCluster) + if !ok || newHC == nil { + return false + } + + return hostedClusterDeletionTimestampChanged(oldHC, newHC) || + hostedClusterActionableAnnotationChanged(oldHC.GetAnnotations(), newHC.GetAnnotations()) || + hostedClusterActionableLabelChanged(oldHC.GetLabels(), newHC.GetLabels()) + }, + }, + ), + ) +} + +func hostedClusterActionableAnnotationChanged(oldAnnotations, newAnnotations map[string]string) bool { + for _, key := range hostedClusterMirroredAnnotations { + if annotationValueChanged(oldAnnotations, newAnnotations, key) { + return true + } + } + + for _, prefix := range hostedClusterActionableAnnotationPrefixes { + if prefixedKeyChanged(oldAnnotations, newAnnotations, prefix) { + return true + } + } + + for _, key := range hostedClusterAdditionalActionableAnnotations { + if annotationValueChanged(oldAnnotations, newAnnotations, key) { + return true + } + } + + return false +} + +func annotationValueChanged(oldAnnotations, newAnnotations map[string]string, key string) bool { + oldValue, oldHasValue := oldAnnotations[key] + newValue, newHasValue := newAnnotations[key] + + return oldHasValue != newHasValue || oldValue != newValue +} + +func prefixedKeyChanged(old, new map[string]string, prefix string) bool { + oldEntries := prefixedEntries(old, prefix) + newEntries := prefixedEntries(new, prefix) + + if len(oldEntries) != len(newEntries) { + return true + } + + for key, oldValue := range oldEntries { + if newValue, ok := newEntries[key]; !ok || newValue != oldValue { + return true + } + } + + return false +} + +func prefixedEntries(m map[string]string, prefix string) map[string]string { + result := map[string]string{} + for key, value := range m { + if strings.HasPrefix(key, prefix) { + result[key] = value + } + } + return result +} + +func hostedClusterActionableLabelChanged(oldLabels, newLabels map[string]string) bool { + for _, prefix := range hostedClusterActionableLabelPrefixes { + if prefixedKeyChanged(oldLabels, newLabels, prefix) { + return true + } + } + + return false +} + +func hostedClusterDeletionTimestampChanged(oldHC, newHC *hyperv1.HostedCluster) bool { + oldDeletionTimestamp := oldHC.GetDeletionTimestamp() + newDeletionTimestamp := newHC.GetDeletionTimestamp() + + switch { + case oldDeletionTimestamp == nil && newDeletionTimestamp == nil: + return false + case oldDeletionTimestamp == nil || newDeletionTimestamp == nil: + return true + default: + return !oldDeletionTimestamp.Equal(newDeletionTimestamp) + } +} diff --git a/hypershift-operator/controllers/hostedcluster/hostedcluster_predicates_test.go b/hypershift-operator/controllers/hostedcluster/hostedcluster_predicates_test.go new file mode 100644 index 00000000000..ff222b13325 --- /dev/null +++ b/hypershift-operator/controllers/hostedcluster/hostedcluster_predicates_test.go @@ -0,0 +1,380 @@ +package hostedcluster + +import ( + "testing" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/support/api" + "github.com/openshift/hypershift/support/k8sutil" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +var platformProviderOverrideAnnotations = []struct { + name string + annotation string +}{ + {name: "When the AWS provider image override changes, it should return true", annotation: hyperv1.ClusterAPIProviderAWSImage}, + {name: "When the Azure provider image override changes, it should return true", annotation: hyperv1.ClusterAPIAzureProviderImage}, + {name: "When the GCP provider image override changes, it should return true", annotation: hyperv1.ClusterAPIGCPProviderImage}, + {name: "When the agent provider image override changes, it should return true", annotation: hyperv1.ClusterAPIAgentProviderImage}, + {name: "When the KubeVirt provider image override changes, it should return true", annotation: hyperv1.ClusterAPIKubeVirtProviderImage}, + {name: "When the PowerVS provider image override changes, it should return true", annotation: hyperv1.ClusterAPIPowerVSProviderImage}, + {name: "When the OpenStack provider image override changes, it should return true", annotation: hyperv1.ClusterAPIOpenStackProviderImage}, + {name: "When the OpenStack resource controller image override changes, it should return true", annotation: hyperv1.OpenStackResourceControllerImage}, +} + +func TestHostedClusterActionableAnnotationChanged_WhenAnnotationsChangeItShouldReturnExpectedResult(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + oldAnnotations map[string]string + newAnnotations map[string]string + expected bool + }{ + { + name: "When a mirrored annotation changes, it should return true", + oldAnnotations: map[string]string{ + hyperv1.CleanupCloudResourcesAnnotation: "false", + }, + newAnnotations: map[string]string{ + hyperv1.CleanupCloudResourcesAnnotation: "true", + }, + expected: true, + }, + { + name: "When a prefixed annotation changes, it should return true", + oldAnnotations: map[string]string{ + hyperv1.IdentityProviderOverridesAnnotationPrefix + "-example": "one", + }, + newAnnotations: map[string]string{ + hyperv1.IdentityProviderOverridesAnnotationPrefix + "-example": "two", + }, + expected: true, + }, + { + name: "When the restart annotation changes, it should return true", + oldAnnotations: map[string]string{ + hyperv1.RestartDateAnnotation: "2026-05-05T10:00:00Z", + }, + newAnnotations: map[string]string{ + hyperv1.RestartDateAnnotation: "2026-05-05T10:05:00Z", + }, + expected: true, + }, + { + name: "When a direct reconciliation annotation changes, it should return true", + oldAnnotations: map[string]string{ + hyperv1.ForceUpgradeToAnnotation: "quay.io/openshift-release-dev/ocp-release:4.19.0-x86_64", + }, + newAnnotations: map[string]string{ + hyperv1.ForceUpgradeToAnnotation: "quay.io/openshift-release-dev/ocp-release:4.19.1-x86_64", + }, + expected: true, + }, + { + name: "When the kubevirt escape hatch annotation changes, it should return true", + oldAnnotations: map[string]string{ + hyperv1.AllowUnsupportedKubeVirtRHCOSVariantsAnnotation: "false", + }, + newAnnotations: map[string]string{ + hyperv1.AllowUnsupportedKubeVirtRHCOSVariantsAnnotation: "true", + }, + expected: true, + }, + { + name: "When the destroy grace period annotation changes, it should return true", + oldAnnotations: map[string]string{ + hyperv1.HCDestroyGracePeriodAnnotation: "5m", + }, + newAnnotations: map[string]string{ + hyperv1.HCDestroyGracePeriodAnnotation: "10m", + }, + expected: true, + }, + { + name: "When the pod security override annotation changes, it should return true", + oldAnnotations: map[string]string{ + hyperv1.PodSecurityAdmissionLabelOverrideAnnotation: "privileged", + }, + newAnnotations: map[string]string{ + hyperv1.PodSecurityAdmissionLabelOverrideAnnotation: "restricted", + }, + expected: true, + }, + { + name: "When the cluster API manager image annotation changes, it should return true", + oldAnnotations: map[string]string{ + hyperv1.ClusterAPIManagerImage: "quay.io/example/capi:v1", + }, + newAnnotations: map[string]string{ + hyperv1.ClusterAPIManagerImage: "quay.io/example/capi:v2", + }, + expected: true, + }, + { + name: "When the skip release validation annotation changes, it should return true", + oldAnnotations: map[string]string{ + hyperv1.SkipReleaseImageValidation: "true", + }, + newAnnotations: map[string]string{}, + expected: true, + }, + { + name: "When the skip KAS conflict SAN validation annotation changes, it should return true", + oldAnnotations: map[string]string{ + hyperv1.SkipKASConflicSANValidation: "true", + }, + newAnnotations: map[string]string{}, + expected: true, + }, + { + name: "When the scope annotation changes, it should return true", + oldAnnotations: map[string]string{ + k8sutil.HostedClustersScopeAnnotation: "one", + }, + newAnnotations: map[string]string{ + k8sutil.HostedClustersScopeAnnotation: "two", + }, + expected: true, + }, + { + name: "When a non action annotation changes, it should return false", + oldAnnotations: map[string]string{ + "example.com/ignored": "old", + }, + newAnnotations: map[string]string{ + "example.com/ignored": "new", + }, + expected: false, + }, + { + name: "When actionable annotations do not change, it should return false", + oldAnnotations: map[string]string{ + hyperv1.CleanupCloudResourcesAnnotation: "true", + }, + newAnnotations: map[string]string{ + hyperv1.CleanupCloudResourcesAnnotation: "true", + }, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if actual := hostedClusterActionableAnnotationChanged(tc.oldAnnotations, tc.newAnnotations); actual != tc.expected { + t.Fatalf("expected actionable annotation change to be %t, got %t", tc.expected, actual) + } + }) + } +} + +func TestHostedClusterActionableAnnotationChanged_WhenAPlatformProviderOverrideChangesItShouldReturnTrue(t *testing.T) { + t.Parallel() + + for _, tc := range platformProviderOverrideAnnotations { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if actual := hostedClusterActionableAnnotationChanged( + map[string]string{tc.annotation: "old"}, + map[string]string{tc.annotation: "new"}, + ); !actual { + t.Fatalf("expected annotation %s to be actionable", tc.annotation) + } + }) + } +} + +func TestHostedClusterPrimaryPredicate_WhenHostedClusterUpdatesItShouldFilterMeaningfulChanges(t *testing.T) { + t.Setenv(k8sutil.EnableHostedClustersAnnotationScopingEnv, "") + t.Setenv(k8sutil.HostedClustersScopeAnnotationEnv, "") + + pred := hostedClusterPrimaryPredicate(fake.NewClientBuilder().WithScheme(api.Scheme).Build()) + + testCases := []struct { + name string + oldHC *hyperv1.HostedCluster + newHC *hyperv1.HostedCluster + expected bool + }{ + { + name: "When the generation changes, it should allow the update", + oldHC: newHostedClusterForPredicateTests(1, nil), + newHC: newHostedClusterForPredicateTests(2, nil), + expected: true, + }, + { + name: "When only status changes, it should skip the update", + oldHC: newHostedClusterForPredicateTests(1, nil), + newHC: func() *hyperv1.HostedCluster { + hc := newHostedClusterForPredicateTests(1, nil) + hc.Status.Conditions = []metav1.Condition{{ + Type: string(hyperv1.ReconciliationSucceeded), + Status: metav1.ConditionTrue, + }} + return hc + }(), + expected: false, + }, + { + name: "When a mirrored annotation changes, it should allow the update", + oldHC: newHostedClusterForPredicateTests(1, map[string]string{ + hyperv1.CleanupCloudResourcesAnnotation: "false", + }), + newHC: newHostedClusterForPredicateTests(1, map[string]string{ + hyperv1.CleanupCloudResourcesAnnotation: "true", + }), + expected: true, + }, + { + name: "When a prefixed annotation changes, it should allow the update", + oldHC: newHostedClusterForPredicateTests(1, map[string]string{ + hyperv1.ResourceRequestOverrideAnnotationPrefix + "-kas": "old", + }), + newHC: newHostedClusterForPredicateTests(1, map[string]string{ + hyperv1.ResourceRequestOverrideAnnotationPrefix + "-kas": "new", + }), + expected: true, + }, + { + name: "When a direct reconciliation annotation changes, it should allow the update", + oldHC: newHostedClusterForPredicateTests(1, map[string]string{ + hyperv1.ForceUpgradeToAnnotation: "quay.io/openshift-release-dev/ocp-release:4.19.0-x86_64", + }), + newHC: newHostedClusterForPredicateTests(1, map[string]string{ + hyperv1.ForceUpgradeToAnnotation: "quay.io/openshift-release-dev/ocp-release:4.19.1-x86_64", + }), + expected: true, + }, + { + name: "When a platform provider override annotation changes, it should allow the update", + oldHC: newHostedClusterForPredicateTests(1, map[string]string{ + hyperv1.ClusterAPIProviderAWSImage: "quay.io/example/aws:v1", + }), + newHC: newHostedClusterForPredicateTests(1, map[string]string{ + hyperv1.ClusterAPIProviderAWSImage: "quay.io/example/aws:v2", + }), + expected: true, + }, + { + name: "When the KAS SAN validation skip annotation changes, it should allow the update", + oldHC: newHostedClusterForPredicateTests(1, map[string]string{ + hyperv1.SkipKASConflicSANValidation: "true", + }), + newHC: newHostedClusterForPredicateTests(1, map[string]string{}), + expected: true, + }, + { + name: "When the scope annotation changes, it should allow the update", + oldHC: newHostedClusterForPredicateTests(1, map[string]string{ + k8sutil.HostedClustersScopeAnnotation: "one", + }), + newHC: newHostedClusterForPredicateTests(1, map[string]string{ + k8sutil.HostedClustersScopeAnnotation: "two", + }), + expected: true, + }, + { + name: "When the deletion timestamp changes, it should allow the update", + oldHC: newHostedClusterForPredicateTests(1, nil), + newHC: func() *hyperv1.HostedCluster { + hc := newHostedClusterForPredicateTests(1, nil) + hc.DeletionTimestamp = ptr.To(metav1.Now()) + return hc + }(), + expected: true, + }, + { + name: "When an actionable label changes, it should allow the update", + oldHC: func() *hyperv1.HostedCluster { + hc := newHostedClusterForPredicateTests(1, nil) + hc.Labels = map[string]string{"api.openshift.com/id": "old"} + return hc + }(), + newHC: func() *hyperv1.HostedCluster { + hc := newHostedClusterForPredicateTests(1, nil) + hc.Labels = map[string]string{"api.openshift.com/id": "new"} + return hc + }(), + expected: true, + }, + { + name: "When an actionable label is removed, it should allow the update", + oldHC: func() *hyperv1.HostedCluster { + hc := newHostedClusterForPredicateTests(1, nil) + hc.Labels = map[string]string{"api.openshift.com/id": "old"} + return hc + }(), + newHC: func() *hyperv1.HostedCluster { + hc := newHostedClusterForPredicateTests(1, nil) + hc.Labels = map[string]string{} + return hc + }(), + expected: true, + }, + { + name: "When a non action annotation changes, it should skip the update", + oldHC: newHostedClusterForPredicateTests(1, map[string]string{ + "example.com/ignored": "old", + }), + newHC: newHostedClusterForPredicateTests(1, map[string]string{ + "example.com/ignored": "new", + }), + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + actual := pred.Update(event.UpdateEvent{ + ObjectOld: tc.oldHC, + ObjectNew: tc.newHC, + }) + if actual != tc.expected { + t.Fatalf("expected predicate result %t, got %t", tc.expected, actual) + } + }) + } +} + +func TestHostedClusterPrimaryPredicate_WhenAHostedClusterBecomesInScopeItShouldAllowTheUpdate(t *testing.T) { + t.Setenv(k8sutil.EnableHostedClustersAnnotationScopingEnv, "true") + t.Setenv(k8sutil.HostedClustersScopeAnnotationEnv, "team-a") + + pred := hostedClusterPrimaryPredicate(fake.NewClientBuilder().WithScheme(api.Scheme).Build()) + + oldHC := newHostedClusterForPredicateTests(1, map[string]string{ + k8sutil.HostedClustersScopeAnnotation: "team-b", + }) + newHC := newHostedClusterForPredicateTests(1, map[string]string{ + k8sutil.HostedClustersScopeAnnotation: "team-a", + }) + + if !pred.Update(event.UpdateEvent{ + ObjectOld: oldHC, + ObjectNew: newHC, + }) { + t.Fatal("expected scope transition into the configured scope to enqueue a reconcile") + } +} + +func newHostedClusterForPredicateTests(generation int64, annotations map[string]string) *hyperv1.HostedCluster { + return &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", + Namespace: "clusters", + Generation: generation, + Annotations: annotations, + }, + } +} diff --git a/hypershift-operator/controllers/hostedcluster/hostedcluster_reconciliation_condition_test.go b/hypershift-operator/controllers/hostedcluster/hostedcluster_reconciliation_condition_test.go new file mode 100644 index 00000000000..27b3e2f1f71 --- /dev/null +++ b/hypershift-operator/controllers/hostedcluster/hostedcluster_reconciliation_condition_test.go @@ -0,0 +1,227 @@ +package hostedcluster + +import ( + "context" + "errors" + "testing" + "time" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/support/api" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + ctrl "sigs.k8s.io/controller-runtime" + crclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/go-logr/logr" +) + +func TestReconcile_WhenReconciliationFinishesItShouldStampObservedGenerationOnTheCondition(t *testing.T) { + t.Parallel() + + reconcilerNow := metav1.Time{Time: time.Now().Round(time.Second)} + const generation int64 = 7 + + testCases := []struct { + name string + reconcileError error + expectedStatus metav1.ConditionStatus + expectedReason string + expectedMsg string + }{ + { + name: "When reconciliation succeeds, it should stamp the current generation", + expectedStatus: metav1.ConditionTrue, + expectedReason: "ReconciliatonSucceeded", + expectedMsg: "Reconciliation completed successfully", + }, + { + name: "When reconciliation fails, it should stamp the failing generation", + reconcileError: errors.New("things went sideways"), + expectedStatus: metav1.ConditionFalse, + expectedReason: "ReconciliationError", + expectedMsg: "things went sideways", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + hcluster := &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", + Namespace: "clusters", + Generation: generation, + }, + } + + c := fake.NewClientBuilder().WithScheme(api.Scheme).WithObjects(hcluster).WithStatusSubresource(hcluster).Build() + r := &HostedClusterReconciler{ + Client: c, + CertRotationScale: 24 * time.Hour, + overwriteReconcile: func(ctx context.Context, req ctrl.Request, log logr.Logger, hcluster *hyperv1.HostedCluster) (ctrl.Result, error) { + return ctrl.Result{}, tc.reconcileError + }, + now: func() metav1.Time { return reconcilerNow }, + } + + _, _ = r.Reconcile(t.Context(), ctrl.Request{NamespacedName: crclient.ObjectKeyFromObject(hcluster)}) + + if err := c.Get(t.Context(), crclient.ObjectKeyFromObject(hcluster), hcluster); err != nil { + t.Fatalf("failed to get hostedcluster after reconciliation: %v", err) + } + + condition := meta.FindStatusCondition(hcluster.Status.Conditions, string(hyperv1.ReconciliationSucceeded)) + if condition == nil { + t.Fatalf("expected %s condition to be set", hyperv1.ReconciliationSucceeded) + } + if condition.ObservedGeneration != generation { + t.Fatalf("expected observed generation %d, got %d", generation, condition.ObservedGeneration) + } + if condition.Status != tc.expectedStatus { + t.Fatalf("expected condition status %s, got %s", tc.expectedStatus, condition.Status) + } + if condition.Reason != tc.expectedReason { + t.Fatalf("expected condition reason %s, got %s", tc.expectedReason, condition.Reason) + } + if condition.Message != tc.expectedMsg { + t.Fatalf("expected condition message %q, got %q", tc.expectedMsg, condition.Message) + } + }) + } +} + +func TestReconcile_WhenQueuedRequestsAreStaleItShouldReadTheLatestHostedClusterGeneration(t *testing.T) { + t.Parallel() + + const latestGeneration int64 = 3 + reconcilerNow := metav1.Time{Time: time.Now().Round(time.Second)} + + hcluster := &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", + Namespace: "clusters", + Generation: 1, + }, + } + + c := fake.NewClientBuilder().WithScheme(api.Scheme).WithObjects(hcluster).WithStatusSubresource(hcluster).Build() + + storedHostedCluster := &hyperv1.HostedCluster{} + if err := c.Get(t.Context(), crclient.ObjectKeyFromObject(hcluster), storedHostedCluster); err != nil { + t.Fatalf("failed to get hostedcluster before updating generation: %v", err) + } + storedHostedCluster.Generation = latestGeneration + if err := c.Update(t.Context(), storedHostedCluster); err != nil { + t.Fatalf("failed to update hostedcluster generation before reconcile: %v", err) + } + + var reconciledGenerations []int64 + r := &HostedClusterReconciler{ + Client: c, + CertRotationScale: 24 * time.Hour, + overwriteReconcile: func(ctx context.Context, req ctrl.Request, log logr.Logger, hcluster *hyperv1.HostedCluster) (ctrl.Result, error) { + reconciledGenerations = append(reconciledGenerations, hcluster.Generation) + return ctrl.Result{}, nil + }, + now: func() metav1.Time { return reconcilerNow }, + } + + request := ctrl.Request{NamespacedName: crclient.ObjectKeyFromObject(hcluster)} + for i := 0; i < 2; i++ { + if _, err := r.Reconcile(t.Context(), request); err != nil { + t.Fatalf("reconcile %d failed: %v", i, err) + } + } + + if len(reconciledGenerations) != 2 { + t.Fatalf("expected 2 reconciliations, got %d", len(reconciledGenerations)) + } + for _, generation := range reconciledGenerations { + if generation != latestGeneration { + t.Fatalf("expected all reconciliations to read generation %d, got %v", latestGeneration, reconciledGenerations) + } + } + + if err := c.Get(t.Context(), crclient.ObjectKeyFromObject(hcluster), storedHostedCluster); err != nil { + t.Fatalf("failed to get hostedcluster after reconciliation: %v", err) + } + condition := meta.FindStatusCondition(storedHostedCluster.Status.Conditions, string(hyperv1.ReconciliationSucceeded)) + if condition == nil { + t.Fatalf("expected %s condition to be set", hyperv1.ReconciliationSucceeded) + } + if condition.ObservedGeneration != latestGeneration { + t.Fatalf("expected observed generation %d, got %d", latestGeneration, condition.ObservedGeneration) + } +} + +func TestReconcile_WhenQueuedRequestsAreStaleAndActionableMetadataChangesItShouldReadTheLatestHostedClusterState(t *testing.T) { + t.Parallel() + + reconcilerNow := metav1.Time{Time: time.Now().Round(time.Second)} + const latestProviderImage = "quay.io/example/aws:v2" + + hcluster := &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", + Namespace: "clusters", + Generation: 7, + Annotations: map[string]string{ + hyperv1.ClusterAPIProviderAWSImage: "quay.io/example/aws:v1", + }, + }, + } + + c := fake.NewClientBuilder().WithScheme(api.Scheme).WithObjects(hcluster).WithStatusSubresource(hcluster).Build() + + storedHostedCluster := &hyperv1.HostedCluster{} + if err := c.Get(t.Context(), crclient.ObjectKeyFromObject(hcluster), storedHostedCluster); err != nil { + t.Fatalf("failed to get hostedcluster before updating annotations: %v", err) + } + storedHostedCluster.Annotations[hyperv1.ClusterAPIProviderAWSImage] = latestProviderImage + if err := c.Update(t.Context(), storedHostedCluster); err != nil { + t.Fatalf("failed to update hostedcluster annotations before reconcile: %v", err) + } + + var reconciledProviderImages []string + r := &HostedClusterReconciler{ + Client: c, + CertRotationScale: 24 * time.Hour, + overwriteReconcile: func(ctx context.Context, req ctrl.Request, log logr.Logger, hcluster *hyperv1.HostedCluster) (ctrl.Result, error) { + reconciledProviderImages = append(reconciledProviderImages, hcluster.Annotations[hyperv1.ClusterAPIProviderAWSImage]) + return ctrl.Result{}, nil + }, + now: func() metav1.Time { return reconcilerNow }, + } + + request := ctrl.Request{NamespacedName: crclient.ObjectKeyFromObject(hcluster)} + for i := 0; i < 2; i++ { + if _, err := r.Reconcile(t.Context(), request); err != nil { + t.Fatalf("reconcile %d failed: %v", i, err) + } + } + + if len(reconciledProviderImages) != 2 { + t.Fatalf("expected 2 reconciliations, got %d", len(reconciledProviderImages)) + } + for _, providerImage := range reconciledProviderImages { + if providerImage != latestProviderImage { + t.Fatalf("expected all reconciliations to read provider image %q, got %v", latestProviderImage, reconciledProviderImages) + } + } + + if err := c.Get(t.Context(), crclient.ObjectKeyFromObject(hcluster), storedHostedCluster); err != nil { + t.Fatalf("failed to get hostedcluster after reconciliation: %v", err) + } + condition := meta.FindStatusCondition(storedHostedCluster.Status.Conditions, string(hyperv1.ReconciliationSucceeded)) + if condition == nil { + t.Fatalf("expected %s condition to be set", hyperv1.ReconciliationSucceeded) + } + if condition.ObservedGeneration != hcluster.Generation { + t.Fatalf("expected observed generation %d, got %d", hcluster.Generation, condition.ObservedGeneration) + } +} diff --git a/hypershift-operator/controllers/hostedcluster/hostedcluster_release_image_validation_test.go b/hypershift-operator/controllers/hostedcluster/hostedcluster_release_image_validation_test.go new file mode 100644 index 00000000000..5ec3777e3e0 --- /dev/null +++ b/hypershift-operator/controllers/hostedcluster/hostedcluster_release_image_validation_test.go @@ -0,0 +1,126 @@ +package hostedcluster + +import ( + "testing" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestShouldValidateReleaseImage_WhenReleaseImageValidationInputsChangeItShouldReturnExpectedResult(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + hcluster *hyperv1.HostedCluster + condition *metav1.Condition + expected bool + }{ + { + name: "When there is no existing condition, it should validate", + hcluster: &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + }, + expected: true, + }, + { + name: "When the existing condition observed generation is stale, it should validate", + hcluster: &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + }, + condition: &metav1.Condition{ + Type: string(hyperv1.ValidReleaseImage), + Status: metav1.ConditionTrue, + ObservedGeneration: 2, + Reason: hyperv1.AsExpectedReason, + }, + expected: true, + }, + { + name: "When the existing condition is not true, it should validate", + hcluster: &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + }, + condition: &metav1.Condition{ + Type: string(hyperv1.ValidReleaseImage), + Status: metav1.ConditionFalse, + ObservedGeneration: 3, + Reason: hyperv1.InvalidImageReason, + }, + expected: true, + }, + { + name: "When the skip annotation remains present with a skipped condition, it should not validate", + hcluster: &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 3, + Annotations: map[string]string{ + hyperv1.SkipReleaseImageValidation: "true", + }, + }, + }, + condition: &metav1.Condition{ + Type: string(hyperv1.ValidReleaseImage), + Status: metav1.ConditionTrue, + ObservedGeneration: 3, + Reason: hyperv1.ReleaseImageValidationSkippedReason, + }, + expected: false, + }, + { + name: "When the skip annotation is removed after a skipped condition, it should validate", + hcluster: &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + }, + condition: &metav1.Condition{ + Type: string(hyperv1.ValidReleaseImage), + Status: metav1.ConditionTrue, + ObservedGeneration: 3, + Reason: hyperv1.ReleaseImageValidationSkippedReason, + }, + expected: true, + }, + { + name: "When the skip annotation is added after a valid condition, it should validate", + hcluster: &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 3, + Annotations: map[string]string{ + hyperv1.SkipReleaseImageValidation: "true", + }, + }, + }, + condition: &metav1.Condition{ + Type: string(hyperv1.ValidReleaseImage), + Status: metav1.ConditionTrue, + ObservedGeneration: 3, + Reason: hyperv1.AsExpectedReason, + }, + expected: true, + }, + { + name: "When the generation and validation inputs are unchanged, it should not validate", + hcluster: &hyperv1.HostedCluster{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + }, + condition: &metav1.Condition{ + Type: string(hyperv1.ValidReleaseImage), + Status: metav1.ConditionTrue, + ObservedGeneration: 3, + Reason: hyperv1.AsExpectedReason, + }, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if actual := shouldValidateReleaseImage(tc.hcluster, tc.condition); actual != tc.expected { + t.Fatalf("expected shouldValidateReleaseImage to return %t, got %t", tc.expected, actual) + } + }) + } +} diff --git a/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hostedcluster_conditions.go b/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hostedcluster_conditions.go index e9e01de0acc..a8e46558c9e 100644 --- a/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hostedcluster_conditions.go +++ b/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hostedcluster_conditions.go @@ -285,6 +285,7 @@ const ( ProxyCABundleInvalidReason = "ProxyCABundleInvalid" PlatformCredentialsNotFoundReason = "PlatformCredentialsNotFound" InvalidImageReason = "InvalidImage" + ReleaseImageValidationSkippedReason = "ReleaseImageValidationSkipped" InvalidIdentityProvider = "InvalidIdentityProvider" PayloadArchNotFoundReason = "PayloadArchNotFound" diff --git a/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hostedcluster_types.go b/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hostedcluster_types.go index c2d63498870..849ca5d9643 100644 --- a/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hostedcluster_types.go +++ b/vendor/github.com/openshift/hypershift/api/hypershift/v1beta1/hostedcluster_types.go @@ -188,6 +188,10 @@ const ( // resource-request-override.hypershift.openshift.io/kube-apiserver.kube-apiserver: memory=3Gi,cpu=2000m ResourceRequestOverrideAnnotationPrefix = "resource-request-override.hypershift.openshift.io" + // OCMLabelPrefix is the label key prefix used by OCM to tag HostedCluster + // resources. Labels with this prefix are mirrored to the HostedControlPlane. + OCMLabelPrefix = "api.openshift.com/" + // LimitedSupportLabel is a label that can be used by consumers to indicate // a cluster is somehow out of regular support policy. // https://docs.openshift.com/rosa/rosa_architecture/rosa_policy_service_definition/rosa-service-definition.html#rosa-limited-support_rosa-service-definition.