Skip to content

Commit e0ad98e

Browse files
🌱 Implement v1beta2 conditions for HetznerCluster (#1964)
implement v1beta2 conditions for HetznerCluster Implement the v1beta2 counter-part for the current conditions. Additionally introduce condition `Deleting` which is set when the HetznerCluster is deleted. Also rename `HetznerAPIReachable` condition to `HCloudRateLimitExceeded`. Key Points: - Conditions `Deleting` and `HCloudRateLimitExceeded` are conditions with negative polarity. - Conditions `NetworkReady` `LoadBalancerReady` and `HCloudRateLimitExceeded` are set optionally. Therefore ignored if absent while calculating the summary condition. Signed-off-by: Dhairya Arora <dhairya.arora@syself.com>
1 parent 2306838 commit e0ad98e

17 files changed

Lines changed: 795 additions & 27 deletions

.golangci.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,12 +190,15 @@ linters:
190190
# helper packages. We intentionally keep these imports while CAPH stays on the
191191
# v1beta1 Cluster API contract (removed upstream tentatively Aug 2026). Suppress
192192
# the per-import deprecation warnings so real SA1019 hits still surface.
193+
# The conditions/v1beta2 subpath is the v1beta2 helper package designed for
194+
# v1beta1-contract resources (HCloudMachine etc. store v1beta2 conditions in a
195+
# separate field)
193196
- linters:
194197
- staticcheck
195198
text: 'SA1019: "sigs\.k8s\.io/cluster-api/api/core/v1beta1" is deprecated'
196199
- linters:
197200
- staticcheck
198-
text: 'SA1019: "sigs\.k8s\.io/cluster-api/util/deprecated/v1beta1/(patch|conditions)" is deprecated'
201+
text: 'SA1019: "sigs\.k8s\.io/cluster-api/util/deprecated/v1beta1/(patch|conditions(/v1beta2)?)" is deprecated'
199202
# CAPI deprecated clusterv1.ConditionType (the v1beta1 condition typedef) when the v1beta2
200203
# condition list landed. Test helpers still need it to assert against legacy-shadow conditions.
201204
- linters:

api/v1beta1/conditions_const.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,3 +295,100 @@ const (
295295
// BootIDEmptyReason indicates that an empty boot ID is present on the node object.
296296
BootIDEmptyReason = "BootIDEmpty"
297297
)
298+
299+
// v1beta2 conditions.
300+
301+
// common conditions used across resource types.
302+
303+
const (
304+
// HCloudRateLimitExceededV1Beta2Condition reports on whether the HCloud API rate limit has been exceeded.
305+
HCloudRateLimitExceededV1Beta2Condition = "HCloudRateLimitExceeded"
306+
// HCloudRateLimitExceededV1Beta2Reason indicates that the HCloud API rate limit has been exceeded.
307+
HCloudRateLimitExceededV1Beta2Reason = "Exceeded"
308+
)
309+
310+
const (
311+
// HCloudTokenAvailableV1Beta2Condition reports on whether the HCloud Token is available.
312+
HCloudTokenAvailableV1Beta2Condition = "HCloudTokenAvailable"
313+
// HCloudTokenAvailableV1Beta2Reason indicates that the HCloudToken is available.
314+
HCloudTokenAvailableV1Beta2Reason = clusterv1beta1.AvailableV1Beta2Reason
315+
// HCloudTokenInvalidV1Beta2Reason indicates that the HCloudToken is invalid.
316+
HCloudTokenInvalidV1Beta2Reason = "Invalid"
317+
// SecretUnreachableV1Beta2Reason indicates that secret containing the HCloudToken is unreachable.
318+
SecretUnreachableV1Beta2Reason = "SecretUnreachable" // #nosec
319+
)
320+
321+
const (
322+
// InternalErrorV1Beta2Reason indicates an internal error in reconciler.
323+
InternalErrorV1Beta2Reason = "InternalError"
324+
)
325+
326+
// HetznerCluster's v1beta2 conditions.
327+
328+
const (
329+
// NetworkReadyV1Beta2Condition reports on whether the network is ready.
330+
NetworkReadyV1Beta2Condition = "NetworkReady"
331+
// NetworkReadyV1Beta2Reason indicates that the network is ready.
332+
NetworkReadyV1Beta2Reason = clusterv1beta1.ReadyV1Beta2Reason
333+
// NetworkReconcilingFailedV1Beta2Reason indicates that reconciling the network failed.
334+
NetworkReconcilingFailedV1Beta2Reason = "ReconcilingFailed"
335+
)
336+
337+
const (
338+
// LoadBalancerReadyV1Beta2Condition reports on whether a control plane load balancer was successfully reconciled.
339+
LoadBalancerReadyV1Beta2Condition = "LoadBalancerReady"
340+
// LoadBalancerReadyV1Beta2Reason indicates that a control plane load balancer is ready.
341+
LoadBalancerReadyV1Beta2Reason = clusterv1beta1.ReadyV1Beta2Reason
342+
// LoadBalancerCreationFailedV1Beta2Reason indicates that load balancer creation failed.
343+
LoadBalancerCreationFailedV1Beta2Reason = "CreationFailed"
344+
// LoadBalancerReadyMissingControlPlaneEndpointV1Beta2Reason indicates that the control plane endpoint is not set.
345+
LoadBalancerReadyMissingControlPlaneEndpointV1Beta2Reason = "MissingControlPlaneEndpoint"
346+
// LoadBalancerReadySyncingServicesFailedV1Beta2Reason indicates that there an error occurred while syncing services of load balancer.
347+
LoadBalancerReadySyncingServicesFailedV1Beta2Reason = "SyncingServicesFailed"
348+
// LoadBalancerReadyAttachingToNetworkFailedV1Beta2Reason indicates that the server could not be attached to network.
349+
LoadBalancerReadyAttachingToNetworkFailedV1Beta2Reason = "AttachingToNetworkFailed"
350+
// LoadBalancerOwningFailedV1Beta2Reason indicates no owned label could be set on a load balancer.
351+
LoadBalancerOwningFailedV1Beta2Reason = "OwningFailed"
352+
// LoadBalancerUpdateFailedV1Beta2Reason indicates that an error occurred during load balancer update.
353+
LoadBalancerUpdateFailedV1Beta2Reason = "UpdateFailed"
354+
// LoadBalancerDeletionFailedV1Beta2Reason indicates that an error occurred during load balancer delete.
355+
LoadBalancerDeletionFailedV1Beta2Reason = "DeletionFailed"
356+
)
357+
358+
const (
359+
// PlacementGroupsSyncedV1Beta2Condition reports on whether the placement groups are successfully synced.
360+
PlacementGroupsSyncedV1Beta2Condition = "PlacementGroupsSynced"
361+
// PlacementGroupsSyncingFailedV1Beta2Reason indicates that syncing the placement groups failed.
362+
PlacementGroupsSyncingFailedV1Beta2Reason = "SyncingFailed"
363+
// PlacementGroupsSyncedV1Beta2Reason indicates that placement groups are synced successfully.
364+
PlacementGroupsSyncedV1Beta2Reason = "Synced"
365+
)
366+
367+
const (
368+
// ControlPlaneEndpointSetV1Beta2Condition reports on whether the control plane endpoint is set.
369+
ControlPlaneEndpointSetV1Beta2Condition = "ControlPlaneEndpointSet"
370+
// ControlPlaneEndpointSetV1Beta2Reason indicates that the control plane endpoint is set.
371+
ControlPlaneEndpointSetV1Beta2Reason = "Set"
372+
// ControlPlaneEndpointNotSetV1Beta2Reason indicates that the control plane endpoint is not set.
373+
ControlPlaneEndpointNotSetV1Beta2Reason = "NotSet"
374+
)
375+
376+
const (
377+
// TargetClusterReadyV1Beta2Condition reports on whether the kubeconfig in the target cluster is ready.
378+
TargetClusterReadyV1Beta2Condition = "TargetClusterReady"
379+
// TargetClusterReadyV1Beta2Reason indicates that the kubeconfig in the target cluster is ready.
380+
TargetClusterReadyV1Beta2Reason = clusterv1beta1.ReadyV1Beta2Reason
381+
// TargetClusterCreationFailedV1Beta2Reason indicates that the target cluster could not be created.
382+
TargetClusterCreationFailedV1Beta2Reason = "CreationFailed"
383+
)
384+
385+
const (
386+
// TargetClusterSecretReadyV1Beta2Condition reports on whether the hetzner secret in the target cluster is ready.
387+
TargetClusterSecretReadyV1Beta2Condition = "TargetClusterSecretReady"
388+
// TargetClusterSecretReadyV1Beta2Reason indicates that the the hetzner secret in the target cluster is ready.
389+
TargetClusterSecretReadyV1Beta2Reason = clusterv1beta1.ReadyV1Beta2Reason
390+
// TargetClusterControlPlaneNotReadyV1Beta2Reason indicates that the target cluster's control plane is not ready yet.
391+
TargetClusterControlPlaneNotReadyV1Beta2Reason = "ControlPlaneNotReady"
392+
// TargetClusterSyncingSecretFailedV1Beta2Reason indicates that the secret could not be synced.
393+
TargetClusterSyncingSecretFailedV1Beta2Reason = "SyncingSecretFailed"
394+
)

api/v1beta1/hetznercluster_types.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package v1beta1
1919
import (
2020
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2121
clusterv1beta1 "sigs.k8s.io/cluster-api/api/core/v1beta1"
22+
v1beta2conditions "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions/v1beta2"
2223
)
2324

2425
const (
@@ -94,6 +95,21 @@ type HetznerClusterStatus struct {
9495
HCloudPlacementGroups []HCloudPlacementGroupStatus `json:"hcloudPlacementGroups,omitempty"`
9596
FailureDomains clusterv1beta1.FailureDomains `json:"failureDomains,omitempty"`
9697
Conditions clusterv1beta1.Conditions `json:"conditions,omitempty"`
98+
99+
// v1beta2 groups all the fields that will be added or modified in HetznerCluster's status with the V1Beta2 version.
100+
// +optional
101+
V1Beta2 *HetznerClusterV1Beta2Status `json:"v1beta2,omitempty"`
102+
}
103+
104+
// HetznerClusterV1Beta2Status groups all the fields that will be added or modified in HetznerCluster with the V1Beta2 version.
105+
// See https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20240916-improve-status-in-CAPI-resources.md for more context.
106+
type HetznerClusterV1Beta2Status struct {
107+
// conditions represents the observations of a HetznerCluster's current state.
108+
// +optional
109+
// +listType=map
110+
// +listMapKey=type
111+
// +kubebuilder:validation:MaxItems=32
112+
Conditions []metav1.Condition `json:"conditions,omitempty"`
97113
}
98114

99115
// +kubebuilder:object:root=true
@@ -137,6 +153,75 @@ func (r *HetznerCluster) SetConditions(conditions clusterv1beta1.Conditions) {
137153
r.Status.Conditions = conditions
138154
}
139155

156+
// GetV1Beta2Conditions returns the set of v1beta2 conditions for the HetznerCluster object.
157+
func (r *HetznerCluster) GetV1Beta2Conditions() []metav1.Condition {
158+
if r.Status.V1Beta2 == nil {
159+
return nil
160+
}
161+
return r.Status.V1Beta2.Conditions
162+
}
163+
164+
// SetV1Beta2Conditions sets v1beta2 conditions for the HetznerCluster object.
165+
func (r *HetznerCluster) SetV1Beta2Conditions(conditions []metav1.Condition) {
166+
if r.Status.V1Beta2 == nil {
167+
r.Status.V1Beta2 = &HetznerClusterV1Beta2Status{}
168+
}
169+
r.Status.V1Beta2.Conditions = conditions
170+
}
171+
172+
// ClusterV1Beta2SummaryOpts returns the v1beta2 summary options for a HetznerCluster.
173+
// It is the single source of truth for which conditions contribute to the Ready summary,
174+
// used both by ClusterScope.Close() and by early-exit error paths that bypass the scope.
175+
func ClusterV1Beta2SummaryOpts() []v1beta2conditions.SummaryOption {
176+
return []v1beta2conditions.SummaryOption{
177+
// The summary is derived from all condition types listed in ForConditionTypes.
178+
// The order matters: it defines the priority in which conditions surface in the summary.
179+
v1beta2conditions.ForConditionTypes{
180+
HCloudTokenAvailableV1Beta2Condition,
181+
HCloudRateLimitExceededV1Beta2Condition,
182+
clusterv1beta1.DeletingV1Beta2Condition,
183+
NetworkReadyV1Beta2Condition,
184+
LoadBalancerReadyV1Beta2Condition,
185+
PlacementGroupsSyncedV1Beta2Condition,
186+
ControlPlaneEndpointSetV1Beta2Condition,
187+
TargetClusterReadyV1Beta2Condition,
188+
TargetClusterSecretReadyV1Beta2Condition,
189+
},
190+
// IgnoreTypesIfMissing lists conditions that may legitimately not be present on the object.
191+
// If any of these are missing, the summary treats them as if they are healthy.
192+
v1beta2conditions.IgnoreTypesIfMissing{
193+
NetworkReadyV1Beta2Condition,
194+
LoadBalancerReadyV1Beta2Condition,
195+
HCloudRateLimitExceededV1Beta2Condition,
196+
clusterv1beta1.DeletingV1Beta2Condition,
197+
},
198+
// CustomMergeStrategy is used only to override the merge reasons, so
199+
// the Ready summary uses CAPI's standard Ready reasons (Ready /
200+
// NotReady / ReadyUnknown) instead of the generic merge defaults
201+
// (IssuesReported / UnknownReported / InfoReported).
202+
//
203+
// Negative polarity is passed directly into GetDefaultMergePriorityFunc
204+
// here. When a CustomMergeStrategy is provided, NewSummaryCondition
205+
// skips the path that wires up the NegativePolarityConditionTypes
206+
// SummaryOption into the default strategy, so the negative-polarity
207+
// types must be specified explicitly inside the strategy.
208+
v1beta2conditions.CustomMergeStrategy{
209+
MergeStrategy: v1beta2conditions.DefaultMergeStrategy(
210+
v1beta2conditions.GetPriorityFunc(v1beta2conditions.GetDefaultMergePriorityFunc(
211+
// conditions with negative polarity
212+
HCloudRateLimitExceededV1Beta2Condition,
213+
clusterv1beta1.DeletingV1Beta2Condition,
214+
)),
215+
v1beta2conditions.ComputeReasonFunc(v1beta2conditions.GetDefaultComputeMergeReasonFunc(
216+
clusterv1beta1.NotReadyV1Beta2Reason,
217+
clusterv1beta1.ReadyUnknownV1Beta2Reason,
218+
clusterv1beta1.ReadyV1Beta2Reason,
219+
)),
220+
),
221+
},
222+
}
223+
}
224+
140225
// ClusterTagKey generates the key for resources associated with a cluster.
141226
func (r *HetznerCluster) ClusterTagKey() string {
142227
return NameHetznerProviderOwned + r.Name

api/v1beta1/zz_generated.deepcopy.go

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/infrastructure.cluster.x-k8s.io_hetznerclusters.yaml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,74 @@ spec:
512512
ready:
513513
default: false
514514
type: boolean
515+
v1beta2:
516+
description: v1beta2 groups all the fields that will be added or modified
517+
in HetznerCluster's status with the V1Beta2 version.
518+
properties:
519+
conditions:
520+
description: conditions represents the observations of a HetznerCluster's
521+
current state.
522+
items:
523+
description: Condition contains details for one aspect of the
524+
current state of this API Resource.
525+
properties:
526+
lastTransitionTime:
527+
description: |-
528+
lastTransitionTime is the last time the condition transitioned from one status to another.
529+
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
530+
format: date-time
531+
type: string
532+
message:
533+
description: |-
534+
message is a human readable message indicating details about the transition.
535+
This may be an empty string.
536+
maxLength: 32768
537+
type: string
538+
observedGeneration:
539+
description: |-
540+
observedGeneration represents the .metadata.generation that the condition was set based upon.
541+
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
542+
with respect to the current state of the instance.
543+
format: int64
544+
minimum: 0
545+
type: integer
546+
reason:
547+
description: |-
548+
reason contains a programmatic identifier indicating the reason for the condition's last transition.
549+
Producers of specific condition types may define expected values and meanings for this field,
550+
and whether the values are considered a guaranteed API.
551+
The value should be a CamelCase string.
552+
This field may not be empty.
553+
maxLength: 1024
554+
minLength: 1
555+
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
556+
type: string
557+
status:
558+
description: status of the condition, one of True, False,
559+
Unknown.
560+
enum:
561+
- "True"
562+
- "False"
563+
- Unknown
564+
type: string
565+
type:
566+
description: type of condition in CamelCase or in foo.example.com/CamelCase.
567+
maxLength: 316
568+
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
569+
type: string
570+
required:
571+
- lastTransitionTime
572+
- message
573+
- reason
574+
- status
575+
- type
576+
type: object
577+
maxItems: 32
578+
type: array
579+
x-kubernetes-list-map-keys:
580+
- type
581+
x-kubernetes-list-type: map
582+
type: object
515583
required:
516584
- ready
517585
type: object

controllers/controllers_suite_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import (
3939
clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2"
4040
deprecatedv1beta1conditions "sigs.k8s.io/cluster-api/util/conditions/deprecated/v1beta1"
4141
v1beta1conditions "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions"
42+
v1beta2conditions "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions/v1beta2"
4243
ctrl "sigs.k8s.io/controller-runtime"
4344
"sigs.k8s.io/controller-runtime/pkg/client"
4445
fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
@@ -482,6 +483,41 @@ func isPresentAndTrue(key types.NamespacedName, getter v1beta1conditions.Getter,
482483
return objectCondition.Status == corev1.ConditionTrue
483484
}
484485

486+
func isV1Beta2ConditionWithStatusAndReason(key types.NamespacedName, getter client.Object, condition string, status metav1.ConditionStatus, reason string) bool {
487+
if err := testEnv.Get(ctx, key, getter); err != nil {
488+
return false
489+
}
490+
491+
v1beta2Getter, ok := getter.(v1beta2conditions.Getter)
492+
if !ok || !v1beta2conditions.Has(v1beta2Getter, condition) {
493+
return false
494+
}
495+
496+
objectCondition := v1beta2conditions.Get(v1beta2Getter, condition)
497+
return objectCondition.Status == status && objectCondition.Reason == reason
498+
}
499+
500+
func isPresentAndTrueWithReasonV1Beta2(key types.NamespacedName, getter client.Object, condition string, reason string) bool {
501+
return isV1Beta2ConditionWithStatusAndReason(key, getter, condition, metav1.ConditionTrue, reason)
502+
}
503+
504+
func isPresentAndFalseWithReasonV1Beta2(key types.NamespacedName, getter client.Object, condition string, reason string) bool {
505+
return isV1Beta2ConditionWithStatusAndReason(key, getter, condition, metav1.ConditionFalse, reason)
506+
}
507+
508+
func isAbsentV1Beta2(key types.NamespacedName, getter client.Object, condition string) bool {
509+
if err := testEnv.Get(ctx, key, getter); err != nil {
510+
return false
511+
}
512+
513+
v1beta2Getter, ok := getter.(v1beta2conditions.Getter)
514+
if !ok {
515+
return false
516+
}
517+
518+
return !v1beta2conditions.Has(v1beta2Getter, condition)
519+
}
520+
485521
func hasEvent(ctx context.Context, c client.Client, namespace, involvedObjectName, reason, message string) bool {
486522
eventList := &corev1.EventList{}
487523
if err := c.List(ctx, eventList, client.InNamespace(namespace)); err != nil {

controllers/hcloudmachine_controller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ func (r *HCloudMachineReconciler) Reconcile(ctx context.Context, req reconcile.R
144144
secretManager := secretutil.NewSecretManager(log, r, r.APIReader)
145145
hcloudToken, hetznerSecret, err := getAndValidateHCloudToken(ctx, req.Namespace, hetznerCluster, secretManager)
146146
if err != nil {
147-
return hcloudTokenErrorResult(ctx, err, hcloudMachine, r)
147+
return hcloudTokenErrorResult(ctx, err, hcloudMachine, r, nil)
148148
}
149149

150150
hcc := r.HCloudClientFactory.NewClient(hcloudToken)

0 commit comments

Comments
 (0)