diff --git a/.golangci.yml b/.golangci.yml index f2430a1e1b..25e3853c88 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -186,6 +186,15 @@ linters: - linters: - staticcheck text: 'SA1019: "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions" is deprecated: This package is deprecated and is going to be removed when support for v1beta1 will be dropped. Please see https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20240916-improve-status-in-CAPI-resources.md for more details.' + - linters: + - staticcheck + text: 'SA1019: .*.Status.Ready is deprecated: This field is deprecated and will be removed in a future API version. Use status.conditions to determine the ready state of the cluster.' + - linters: + - staticcheck + text: 'SA1019: .*.Status.FailureReason is deprecated: This field is deprecated and will be removed in a future API version. Use status.conditions to report failures.' + - linters: + - staticcheck + text: 'SA1019: .*.Status.FailureMessage is deprecated: This field is deprecated and will be removed in a future API version. Use status.conditions to report failures.' paths: - zz_generated.*\.go$ - third_party$ diff --git a/api/v1beta1/conditions_consts.go b/api/v1beta1/conditions_consts.go index d84500772f..86dcc086f0 100644 --- a/api/v1beta1/conditions_consts.go +++ b/api/v1beta1/conditions_consts.go @@ -69,3 +69,32 @@ const ( // UnableToFindFloatingIPNetworkReason is used when the floating ip network is not found. UnableToFindFloatingIPNetworkReason = "UnableToFindFloatingIPNetwork" ) + +const ( + // NetworkReadyCondition reports on the current status of the cluster network infrastructure. + // Ready indicates that the network, subnets, and related resources have been successfully provisioned. + NetworkReadyCondition clusterv1beta1.ConditionType = "NetworkReady" + + // RouterReadyCondition reports on the current status of the cluster router infrastructure. + // Ready indicates that the router and its interfaces have been successfully provisioned. + RouterReadyCondition clusterv1beta1.ConditionType = "RouterReady" + + // SecurityGroupsReadyCondition reports on the current status of the cluster security groups. + // Ready indicates that all required security groups have been successfully provisioned. + SecurityGroupsReadyCondition clusterv1beta1.ConditionType = "SecurityGroupsReady" + + // APIEndpointReadyCondition reports on the current status of the cluster API endpoint. + // Ready indicates that the control plane endpoint has been successfully configured. + APIEndpointReadyCondition clusterv1beta1.ConditionType = "APIEndpointReady" + + // NetworkReconcileFailedReason is used when network reconciliation fails. + NetworkReconcileFailedReason = "NetworkCreateFailed" + // SubnetReconcileFailedReason is used when subnet reconciliation fails. + SubnetReconcileFailedReason = "SubnetCreateFailed" + // RouterReconcileFailedReason is used when router reconciliation fails. + RouterReconcileFailedReason = "RouterCreateFailed" + // SecurityGroupReconcileFailedReason is used when security group reconciliation fails. + SecurityGroupReconcileFailedReason = "SecurityGroupCreateFailed" + // APIEndpointConfigFailedReason is used when API endpoint configuration fails. + APIEndpointConfigFailedReason = "APIEndpointConfigFailed" +) diff --git a/api/v1beta1/openstackcluster_types.go b/api/v1beta1/openstackcluster_types.go index 02833b1ea2..3c35efcf35 100644 --- a/api/v1beta1/openstackcluster_types.go +++ b/api/v1beta1/openstackcluster_types.go @@ -196,12 +196,27 @@ type OpenStackClusterSpec struct { IdentityRef OpenStackIdentityReference `json:"identityRef"` } +// ClusterInitialization represents the initialization status of the cluster. +type ClusterInitialization struct { + // Provisioned is set to true when the initial provisioning of the cluster infrastructure is completed. + // The value of this field is never updated after provisioning is completed. + // +optional + Provisioned bool `json:"provisioned,omitempty"` +} + // OpenStackClusterStatus defines the observed state of OpenStackCluster. type OpenStackClusterStatus struct { // Ready is true when the cluster infrastructure is ready. + // + // Deprecated: This field is deprecated and will be removed in a future API version. + // Use status.conditions to determine the ready state of the cluster. // +kubebuilder:default=false Ready bool `json:"ready"` + // Initialization contains information about the initialization status of the cluster. + // +optional + Initialization *ClusterInitialization `json:"initialization,omitempty"` + // Network contains information about the created OpenStack Network. // +optional Network *NetworkStatusWithSubnets `json:"network,omitempty"` @@ -257,6 +272,9 @@ type OpenStackClusterStatus struct { // Any transient errors that occur during the reconciliation of // OpenStackClusters can be added as events to the OpenStackCluster object // and/or logged in the controller's output. + // + // Deprecated: This field is deprecated and will be removed in a future API version. + // Use status.conditions to report failures. // +optional FailureReason *capoerrors.DeprecatedCAPIClusterStatusError `json:"failureReason,omitempty"` @@ -276,8 +294,18 @@ type OpenStackClusterStatus struct { // Any transient errors that occur during the reconciliation of // OpenStackClusters can be added as events to the OpenStackCluster object // and/or logged in the controller's output. + // + // Deprecated: This field is deprecated and will be removed in a future API version. + // Use status.conditions to report failures. // +optional FailureMessage *string `json:"failureMessage,omitempty"` + + // Conditions defines current service state of the OpenStackCluster. + // This field surfaces into Cluster's status.conditions[InfrastructureReady] condition. + // The Ready condition must surface issues during the entire lifecycle of the OpenStackCluster + // (both during initial provisioning and after the initial provisioning is completed). + // +optional + Conditions clusterv1beta1.Conditions `json:"conditions,omitempty"` } // +genclient @@ -344,6 +372,16 @@ type ManagedSecurityGroups struct { var _ IdentityRefProvider = &OpenStackCluster{} +// GetConditions returns the observations of the operational state of the OpenStackCluster resource. +func (c *OpenStackCluster) GetConditions() clusterv1beta1.Conditions { + return c.Status.Conditions +} + +// SetConditions sets the underlying service state of the OpenStackCluster to the predescribed clusterv1.Conditions. +func (c *OpenStackCluster) SetConditions(conditions clusterv1beta1.Conditions) { + c.Status.Conditions = conditions +} + // GetIdentifyRef returns the cluster's namespace and IdentityRef. func (c *OpenStackCluster) GetIdentityRef() (*string, *OpenStackIdentityReference) { return &c.Namespace, &c.Spec.IdentityRef diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index b93422b7a3..0ff6155cab 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -280,6 +280,21 @@ func (in *BlockDeviceVolume) DeepCopy() *BlockDeviceVolume { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterInitialization) DeepCopyInto(out *ClusterInitialization) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterInitialization. +func (in *ClusterInitialization) DeepCopy() *ClusterInitialization { + if in == nil { + return nil + } + out := new(ClusterInitialization) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ExternalRouterIPParam) DeepCopyInto(out *ExternalRouterIPParam) { *out = *in @@ -765,6 +780,11 @@ func (in *OpenStackClusterSpec) DeepCopy() *OpenStackClusterSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OpenStackClusterStatus) DeepCopyInto(out *OpenStackClusterStatus) { *out = *in + if in.Initialization != nil { + in, out := &in.Initialization, &out.Initialization + *out = new(ClusterInitialization) + **out = **in + } if in.Network != nil { in, out := &in.Network, &out.Network *out = new(NetworkStatusWithSubnets) @@ -822,6 +842,13 @@ func (in *OpenStackClusterStatus) DeepCopyInto(out *OpenStackClusterStatus) { *out = new(string) **out = **in } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(corev1beta1.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackClusterStatus. diff --git a/cmd/models-schema/zz_generated.openapi.go b/cmd/models-schema/zz_generated.openapi.go index a6c2ea4334..84609b7d86 100644 --- a/cmd/models-schema/zz_generated.openapi.go +++ b/cmd/models-schema/zz_generated.openapi.go @@ -339,6 +339,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.BindingProfile": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_BindingProfile(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.BlockDeviceStorage": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_BlockDeviceStorage(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.BlockDeviceVolume": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_BlockDeviceVolume(ref), + "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.ClusterInitialization": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_ClusterInitialization(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.ExternalRouterIPParam": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_ExternalRouterIPParam(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.FilterByNeutronTags": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_FilterByNeutronTags(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.FixedIP": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_FixedIP(ref), @@ -18054,6 +18055,26 @@ func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_BlockDeviceVolu } } +func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_ClusterInitialization(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "ClusterInitialization represents the initialization status of the cluster.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "provisioned": { + SchemaProps: spec.SchemaProps{ + Description: "Provisioned is set to true when the initial provisioning of the cluster infrastructure is completed. The value of this field is never updated after provisioning is completed.", + Type: []string{"boolean"}, + Format: "", + }, + }, + }, + }, + }, + } +} + func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_ExternalRouterIPParam(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -19040,12 +19061,18 @@ func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackCluste Properties: map[string]spec.Schema{ "ready": { SchemaProps: spec.SchemaProps{ - Description: "Ready is true when the cluster infrastructure is ready.", + Description: "Ready is true when the cluster infrastructure is ready.\n\nDeprecated: This field is deprecated and will be removed in a future API version. Use status.conditions to determine the ready state of the cluster.", Default: false, Type: []string{"boolean"}, Format: "", }, }, + "initialization": { + SchemaProps: spec.SchemaProps{ + Description: "Initialization contains information about the initialization status of the cluster.", + Ref: ref("sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.ClusterInitialization"), + }, + }, "network": { SchemaProps: spec.SchemaProps{ Description: "Network contains information about the created OpenStack Network.", @@ -19111,24 +19138,38 @@ func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackCluste }, "failureReason": { SchemaProps: spec.SchemaProps{ - Description: "FailureReason will be set in the event that there is a terminal problem reconciling the OpenStackCluster and will contain a succinct value suitable for machine interpretation.\n\nThis field should not be set for transitive errors that a controller faces that are expected to be fixed automatically over time (like service outages), but instead indicate that something is fundamentally wrong with the OpenStackCluster's spec or the configuration of the controller, and that manual intervention is required. Examples of terminal errors would be invalid combinations of settings in the spec, values that are unsupported by the controller, or the responsible controller itself being critically misconfigured.\n\nAny transient errors that occur during the reconciliation of OpenStackClusters can be added as events to the OpenStackCluster object and/or logged in the controller's output.", + Description: "FailureReason will be set in the event that there is a terminal problem reconciling the OpenStackCluster and will contain a succinct value suitable for machine interpretation.\n\nThis field should not be set for transitive errors that a controller faces that are expected to be fixed automatically over time (like service outages), but instead indicate that something is fundamentally wrong with the OpenStackCluster's spec or the configuration of the controller, and that manual intervention is required. Examples of terminal errors would be invalid combinations of settings in the spec, values that are unsupported by the controller, or the responsible controller itself being critically misconfigured.\n\nAny transient errors that occur during the reconciliation of OpenStackClusters can be added as events to the OpenStackCluster object and/or logged in the controller's output.\n\nDeprecated: This field is deprecated and will be removed in a future API version. Use status.conditions to report failures.", Type: []string{"string"}, Format: "", }, }, "failureMessage": { SchemaProps: spec.SchemaProps{ - Description: "FailureMessage will be set in the event that there is a terminal problem reconciling the OpenStackCluster and will contain a more verbose string suitable for logging and human consumption.\n\nThis field should not be set for transitive errors that a controller faces that are expected to be fixed automatically over time (like service outages), but instead indicate that something is fundamentally wrong with the OpenStackCluster's spec or the configuration of the controller, and that manual intervention is required. Examples of terminal errors would be invalid combinations of settings in the spec, values that are unsupported by the controller, or the responsible controller itself being critically misconfigured.\n\nAny transient errors that occur during the reconciliation of OpenStackClusters can be added as events to the OpenStackCluster object and/or logged in the controller's output.", + Description: "FailureMessage will be set in the event that there is a terminal problem reconciling the OpenStackCluster and will contain a more verbose string suitable for logging and human consumption.\n\nThis field should not be set for transitive errors that a controller faces that are expected to be fixed automatically over time (like service outages), but instead indicate that something is fundamentally wrong with the OpenStackCluster's spec or the configuration of the controller, and that manual intervention is required. Examples of terminal errors would be invalid combinations of settings in the spec, values that are unsupported by the controller, or the responsible controller itself being critically misconfigured.\n\nAny transient errors that occur during the reconciliation of OpenStackClusters can be added as events to the OpenStackCluster object and/or logged in the controller's output.\n\nDeprecated: This field is deprecated and will be removed in a future API version. Use status.conditions to report failures.", Type: []string{"string"}, Format: "", }, }, + "conditions": { + SchemaProps: spec.SchemaProps{ + Description: "Conditions defines current service state of the OpenStackCluster. This field surfaces into Cluster's status.conditions[InfrastructureReady] condition. The Ready condition must surface issues during the entire lifecycle of the OpenStackCluster (both during initial provisioning and after the initial provisioning is completed).", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("sigs.k8s.io/cluster-api/api/core/v1beta1.Condition"), + }, + }, + }, + }, + }, }, Required: []string{"ready"}, }, }, Dependencies: []string{ - "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.BastionStatus", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.LoadBalancer", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.NetworkStatus", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.NetworkStatusWithSubnets", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.Router", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.SecurityGroupStatus", "sigs.k8s.io/cluster-api/api/core/v1beta1.FailureDomainSpec"}, + "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.BastionStatus", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.ClusterInitialization", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.LoadBalancer", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.NetworkStatus", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.NetworkStatusWithSubnets", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.Router", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.SecurityGroupStatus", "sigs.k8s.io/cluster-api/api/core/v1beta1.Condition", "sigs.k8s.io/cluster-api/api/core/v1beta1.FailureDomainSpec"}, } } diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml index 4cf9b3daec..c20a48a004 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml @@ -2461,6 +2461,62 @@ spec: - id - name type: object + conditions: + description: |- + Conditions defines current service state of the OpenStackCluster. + This field surfaces into Cluster's status.conditions[InfrastructureReady] condition. + The Ready condition must surface issues during the entire lifecycle of the OpenStackCluster + (both during initial provisioning and after the initial provisioning is completed). + items: + description: Condition defines an observation of a Cluster API resource + operational state. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This field may be empty. + maxLength: 10240 + minLength: 1 + type: string + reason: + description: |- + reason is the reason for the condition's last transition in CamelCase. + The specific API may choose whether or not this field is considered a guaranteed API. + This field may be empty. + maxLength: 256 + minLength: 1 + type: string + severity: + description: |- + severity provides an explicit classification of Reason code, so the users or machines can immediately + understand the current situation and act accordingly. + The Severity field MUST be set only when Status=False. + maxLength: 32 + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions + can be useful (see .node.status.conditions), the ability to deconflict is important. + maxLength: 256 + minLength: 1 + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array controlPlaneSecurityGroup: description: |- ControlPlaneSecurityGroup contains the information about the @@ -2530,6 +2586,9 @@ spec: Any transient errors that occur during the reconciliation of OpenStackClusters can be added as events to the OpenStackCluster object and/or logged in the controller's output. + + Deprecated: This field is deprecated and will be removed in a future API version. + Use status.conditions to report failures. type: string failureReason: description: |- @@ -2549,7 +2608,20 @@ spec: Any transient errors that occur during the reconciliation of OpenStackClusters can be added as events to the OpenStackCluster object and/or logged in the controller's output. + + Deprecated: This field is deprecated and will be removed in a future API version. + Use status.conditions to report failures. type: string + initialization: + description: Initialization contains information about the initialization + status of the cluster. + properties: + provisioned: + description: |- + Provisioned is set to true when the initial provisioning of the cluster infrastructure is completed. + The value of this field is never updated after provisioning is completed. + type: boolean + type: object network: description: Network contains information about the created OpenStack Network. @@ -2592,7 +2664,11 @@ spec: type: object ready: default: false - description: Ready is true when the cluster infrastructure is ready. + description: |- + Ready is true when the cluster infrastructure is ready. + + Deprecated: This field is deprecated and will be removed in a future API version. + Use status.conditions to determine the ready state of the cluster. type: boolean router: description: Router describes the default cluster router diff --git a/controllers/openstackcluster_controller.go b/controllers/openstackcluster_controller.go index 1640be678e..12a7843e00 100644 --- a/controllers/openstackcluster_controller.go +++ b/controllers/openstackcluster_controller.go @@ -37,6 +37,7 @@ import ( "sigs.k8s.io/cluster-api/util" "sigs.k8s.io/cluster-api/util/annotations" "sigs.k8s.io/cluster-api/util/collections" + v1beta1conditions "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions" "sigs.k8s.io/cluster-api/util/patch" "sigs.k8s.io/cluster-api/util/predicates" ctrl "sigs.k8s.io/controller-runtime" @@ -356,6 +357,24 @@ func (r *OpenStackClusterReconciler) reconcileNormal(ctx context.Context, scope openStackCluster.Status.Ready = true openStackCluster.Status.FailureMessage = nil openStackCluster.Status.FailureReason = nil + + // Set initialization.provisioned to true when initial infrastructure provisioning is complete. + // This field should only be set once and never changed afterward, as per CAPI v1beta2 contract. + // We set it here after all core infrastructure (network, router, security groups, control plane endpoint) + // has been successfully provisioned. + if openStackCluster.Status.Initialization == nil { + openStackCluster.Status.Initialization = &infrav1.ClusterInitialization{} + } + if !openStackCluster.Status.Initialization.Provisioned { + openStackCluster.Status.Initialization.Provisioned = true + scope.Logger().Info("Initial cluster infrastructure provisioning completed") + } + + // Set the Ready condition to True when infrastructure is ready. + // This condition surfaces into Cluster's status.conditions[InfrastructureReady]. + // It reflects the current operational state of the cluster infrastructure. + v1beta1conditions.MarkTrue(openStackCluster, clusterv1beta1.ReadyCondition) + scope.Logger().Info("Reconciled Cluster created successfully") result, err := r.reconcileBastion(ctx, scope, cluster, openStackCluster) @@ -724,11 +743,20 @@ func reconcileNetworkComponents(scope *scope.WithLogger, cluster *clusterv1.Clus err = networkingService.ReconcileSecurityGroups(openStackCluster, clusterResourceName) if err != nil { + v1beta1conditions.MarkFalse(openStackCluster, infrav1.SecurityGroupsReadyCondition, infrav1.SecurityGroupReconcileFailedReason, clusterv1beta1.ConditionSeverityError, "Failed to reconcile security groups: %v", err) handleUpdateOSCError(openStackCluster, fmt.Errorf("failed to reconcile security groups: %w", err), false) return fmt.Errorf("failed to reconcile security groups: %w", err) } + v1beta1conditions.MarkTrue(openStackCluster, infrav1.SecurityGroupsReadyCondition) - return reconcileControlPlaneEndpoint(scope, networkingService, openStackCluster, clusterResourceName) + err = reconcileControlPlaneEndpoint(scope, networkingService, openStackCluster, clusterResourceName) + if err != nil { + v1beta1conditions.MarkFalse(openStackCluster, infrav1.APIEndpointReadyCondition, infrav1.APIEndpointConfigFailedReason, clusterv1beta1.ConditionSeverityError, "Failed to reconcile control plane endpoint: %v", err) + return err + } + v1beta1conditions.MarkTrue(openStackCluster, infrav1.APIEndpointReadyCondition) + + return nil } // reconcilePreExistingNetworkComponents reconciles the cluster network status when the cluster is @@ -744,6 +772,7 @@ func reconcilePreExistingNetworkComponents(scope *scope.WithLogger, networkingSe if openStackCluster.Spec.Network != nil { network, err := networkingService.GetNetworkByParam(openStackCluster.Spec.Network) if err != nil { + v1beta1conditions.MarkFalse(openStackCluster, infrav1.NetworkReadyCondition, infrav1.OpenStackErrorReason, clusterv1beta1.ConditionSeverityError, "Failed to find network: %v", err) handleUpdateOSCError(openStackCluster, fmt.Errorf("failed to find network: %w", err), false) return fmt.Errorf("error fetching cluster network: %w", err) } @@ -752,6 +781,7 @@ func reconcilePreExistingNetworkComponents(scope *scope.WithLogger, networkingSe subnets, err := getClusterSubnets(networkingService, openStackCluster) if err != nil { + v1beta1conditions.MarkFalse(openStackCluster, infrav1.NetworkReadyCondition, infrav1.OpenStackErrorReason, clusterv1beta1.ConditionSeverityError, "Failed to get cluster subnets: %v", err) return err } @@ -767,6 +797,7 @@ func reconcilePreExistingNetworkComponents(scope *scope.WithLogger, networkingSe } } if err := utils.ValidateSubnets(capoSubnets); err != nil { + v1beta1conditions.MarkFalse(openStackCluster, infrav1.NetworkReadyCondition, infrav1.OpenStackErrorReason, clusterv1beta1.ConditionSeverityError, "Failed to validate subnets: %v", err) return err } openStackCluster.Status.Network.Subnets = capoSubnets @@ -777,14 +808,18 @@ func reconcilePreExistingNetworkComponents(scope *scope.WithLogger, networkingSe if openStackCluster.Status.Network.ID == "" && len(subnets) > 0 { network, err := networkingService.GetNetworkByID(subnets[0].NetworkID) if err != nil { + v1beta1conditions.MarkFalse(openStackCluster, infrav1.NetworkReadyCondition, infrav1.OpenStackErrorReason, clusterv1beta1.ConditionSeverityError, "Failed to get network by ID: %v", err) return err } setClusterNetwork(openStackCluster, network) } + v1beta1conditions.MarkTrue(openStackCluster, infrav1.NetworkReadyCondition) + if openStackCluster.Spec.Router != nil { router, err := networkingService.GetRouterByParam(openStackCluster.Spec.Router) if err != nil { + v1beta1conditions.MarkFalse(openStackCluster, infrav1.RouterReadyCondition, infrav1.OpenStackErrorReason, clusterv1beta1.ConditionSeverityError, "Failed to find router: %v", err) handleUpdateOSCError(openStackCluster, fmt.Errorf("failed to find router: %w", err), false) return fmt.Errorf("error fetching cluster router: %w", err) } @@ -802,6 +837,7 @@ func reconcilePreExistingNetworkComponents(scope *scope.WithLogger, networkingSe Tags: router.Tags, IPs: routerIPs, } + v1beta1conditions.MarkTrue(openStackCluster, infrav1.RouterReadyCondition) } return nil @@ -812,19 +848,25 @@ func reconcilePreExistingNetworkComponents(scope *scope.WithLogger, networkingSe func reconcileProvisionedNetworkComponents(networkingService *networking.Service, openStackCluster *infrav1.OpenStackCluster, clusterResourceName string) error { err := networkingService.ReconcileNetwork(openStackCluster, clusterResourceName) if err != nil { + v1beta1conditions.MarkFalse(openStackCluster, infrav1.NetworkReadyCondition, infrav1.NetworkReconcileFailedReason, clusterv1beta1.ConditionSeverityError, "Failed to reconcile network: %v", err) handleUpdateOSCError(openStackCluster, fmt.Errorf("failed to reconcile network: %w", err), false) return fmt.Errorf("failed to reconcile network: %w", err) } err = networkingService.ReconcileSubnet(openStackCluster, clusterResourceName) if err != nil { + v1beta1conditions.MarkFalse(openStackCluster, infrav1.NetworkReadyCondition, infrav1.SubnetReconcileFailedReason, clusterv1beta1.ConditionSeverityError, "Failed to reconcile subnets: %v", err) handleUpdateOSCError(openStackCluster, fmt.Errorf("failed to reconcile subnets: %w", err), false) return fmt.Errorf("failed to reconcile subnets: %w", err) } + v1beta1conditions.MarkTrue(openStackCluster, infrav1.NetworkReadyCondition) + err = networkingService.ReconcileRouter(openStackCluster, clusterResourceName) if err != nil { + v1beta1conditions.MarkFalse(openStackCluster, infrav1.RouterReadyCondition, infrav1.RouterReconcileFailedReason, clusterv1beta1.ConditionSeverityError, "Failed to reconcile router: %v", err) handleUpdateOSCError(openStackCluster, fmt.Errorf("failed to reconcile router: %w", err), false) return fmt.Errorf("failed to reconcile router: %w", err) } + v1beta1conditions.MarkTrue(openStackCluster, infrav1.RouterReadyCondition) return nil } @@ -955,6 +997,12 @@ func handleUpdateOSCError(openstackCluster *infrav1.OpenStackCluster, message er err := capoerrors.DeprecatedCAPOUpdateClusterError openstackCluster.Status.FailureReason = &err openstackCluster.Status.FailureMessage = ptr.To(message.Error()) + // Set the Ready condition to False for fatal errors + v1beta1conditions.MarkFalse(openstackCluster, clusterv1beta1.ReadyCondition, infrav1.OpenStackErrorReason, clusterv1beta1.ConditionSeverityError, "%v", message) + } else { + // For transient (non-fatal) errors, set Ready condition to False with Warning severity + // This indicates a temporary issue that may be resolved on retry + v1beta1conditions.MarkFalse(openstackCluster, clusterv1beta1.ReadyCondition, infrav1.OpenStackErrorReason, clusterv1beta1.ConditionSeverityWarning, "%v", message) } } diff --git a/controllers/openstackcluster_controller_test.go b/controllers/openstackcluster_controller_test.go index 548e7a28c1..98f0bd4a8f 100644 --- a/controllers/openstackcluster_controller_test.go +++ b/controllers/openstackcluster_controller_test.go @@ -22,7 +22,9 @@ import ( "reflect" "testing" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/routers" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/security/groups" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/networks" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/subnets" . "github.com/onsi/ginkgo/v2" //nolint:revive @@ -35,6 +37,7 @@ import ( clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" "sigs.k8s.io/cluster-api/test/framework" "sigs.k8s.io/cluster-api/util/annotations" + v1beta1conditions "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions" "sigs.k8s.io/cluster-api/util/patch" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -446,6 +449,11 @@ var _ = Describe("OpenStackCluster controller", func() { err = reconcileNetworkComponents(scope, capiCluster, testCluster) Expect(err).To(BeNil()) + + // Verify conditions are set correctly + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.NetworkReadyCondition)).To(BeTrue()) + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.SecurityGroupsReadyCondition)).To(BeTrue()) + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.APIEndpointReadyCondition)).To(BeTrue()) }) It("should allow two subnets for the cluster network", func() { @@ -528,6 +536,11 @@ var _ = Describe("OpenStackCluster controller", func() { err = reconcileNetworkComponents(scope, capiCluster, testCluster) Expect(err).To(BeNil()) Expect(len(testCluster.Status.Network.Subnets)).To(Equal(2)) + + // Verify conditions are set correctly + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.NetworkReadyCondition)).To(BeTrue()) + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.SecurityGroupsReadyCondition)).To(BeTrue()) + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.APIEndpointReadyCondition)).To(BeTrue()) }) It("should allow fetch network by subnet", func() { @@ -574,6 +587,11 @@ var _ = Describe("OpenStackCluster controller", func() { err = reconcileNetworkComponents(scope, capiCluster, testCluster) Expect(err).To(BeNil()) Expect(testCluster.Status.Network.ID).To(Equal(clusterNetworkID)) + + // Verify conditions are set correctly + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.NetworkReadyCondition)).To(BeTrue()) + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.SecurityGroupsReadyCondition)).To(BeTrue()) + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.APIEndpointReadyCondition)).To(BeTrue()) }) It("reconcile pre-existing network components by id", func() { @@ -634,6 +652,10 @@ var _ = Describe("OpenStackCluster controller", func() { Expect(testCluster.Status.Network.ID).To(Equal(clusterNetworkID)) Expect(testCluster.Status.Network.Subnets[0].ID).To(Equal(clusterSubnetID)) Expect(testCluster.Status.Router.ID).To(Equal(clusterRouterID)) + + // Verify conditions are set correctly + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.NetworkReadyCondition)).To(BeTrue()) + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.RouterReadyCondition)).To(BeTrue()) }) It("reconcile pre-existing network components by name", func() { @@ -716,6 +738,462 @@ var _ = Describe("OpenStackCluster controller", func() { Expect(testCluster.Status.Network.ID).To(Equal(clusterNetworkID)) Expect(testCluster.Status.Network.Subnets[0].ID).To(Equal(clusterSubnetID)) Expect(testCluster.Status.Router.ID).To(Equal(clusterRouterID)) + + // Verify conditions are set correctly + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.NetworkReadyCondition)).To(BeTrue()) + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.RouterReadyCondition)).To(BeTrue()) + }) + + It("should reconcile API endpoint with floating IP and set condition", func() { + const externalNetworkID = "a42211a2-4d2c-426f-9413-830e4b4abbbc" + const clusterNetworkID = "6c90b532-7ba0-418a-a276-5ae55060b5b0" + const clusterSubnetID = "cad5a91a-36de-4388-823b-b0cc82cadfdc" + const floatingIP = "203.0.113.10" + + testCluster.SetName("api-endpoint-floating-ip") + testCluster.Spec = infrav1.OpenStackClusterSpec{ + IdentityRef: infrav1.OpenStackIdentityReference{ + Name: "test-creds", + CloudName: "openstack", + }, + ExternalNetwork: &infrav1.NetworkParam{ + ID: ptr.To(externalNetworkID), + }, + Network: &infrav1.NetworkParam{ + ID: ptr.To(clusterNetworkID), + }, + // When DisableAPIServerFloatingIP is not set and external network is configured, + // a floating IP should be created for the API server + } + err := k8sClient.Create(ctx, testCluster) + Expect(err).To(BeNil()) + err = k8sClient.Create(ctx, capiCluster) + Expect(err).To(BeNil()) + + log := GinkgoLogr + clientScope, err := mockScopeFactory.NewClientScopeFromObject(ctx, k8sClient, nil, log, testCluster) + Expect(err).To(BeNil()) + scope := scope.NewWithLogger(clientScope, log) + + networkClientRecorder := mockScopeFactory.NetworkClient.EXPECT() + + // Fetch external network + networkClientRecorder.GetNetwork(externalNetworkID).Return(&networks.Network{ + ID: externalNetworkID, + Name: "external-network", + }, nil) + + // Fetch cluster network + networkClientRecorder.GetNetwork(clusterNetworkID).Return(&networks.Network{ + ID: clusterNetworkID, + Name: "cluster-network", + }, nil) + + // Fetching cluster subnets + networkClientRecorder.ListSubnet(subnets.ListOpts{ + NetworkID: clusterNetworkID, + }).Return([]subnets.Subnet{ + { + ID: clusterSubnetID, + Name: "cluster-subnet", + CIDR: "192.168.0.0/24", + }, + }, nil) + + // Mock floating IP creation for API server + // When no specific IP is requested, it will just create a new floating IP + networkClientRecorder.CreateFloatingIP(gomock.Any()).Return(&floatingips.FloatingIP{ + FloatingIP: floatingIP, + ID: "floating-ip-id", + }, nil) + + err = reconcileNetworkComponents(scope, capiCluster, testCluster) + Expect(err).To(BeNil()) + + // Verify API endpoint was set + Expect(testCluster.Spec.ControlPlaneEndpoint).ToNot(BeNil()) + Expect(testCluster.Spec.ControlPlaneEndpoint.Host).To(Equal(floatingIP)) + Expect(testCluster.Spec.ControlPlaneEndpoint.Port).To(Equal(int32(6443))) + + // Verify conditions are set correctly + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.NetworkReadyCondition)).To(BeTrue()) + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.SecurityGroupsReadyCondition)).To(BeTrue()) + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.APIEndpointReadyCondition)).To(BeTrue()) + }) + + It("should reconcile API endpoint with fixed IP and set condition", func() { + const clusterNetworkID = "6c90b532-7ba0-418a-a276-5ae55060b5b0" + const clusterSubnetID = "cad5a91a-36de-4388-823b-b0cc82cadfdc" + const fixedIP = "192.168.0.10" + + testCluster.SetName("api-endpoint-fixed-ip") + testCluster.Spec = infrav1.OpenStackClusterSpec{ + IdentityRef: infrav1.OpenStackIdentityReference{ + Name: "test-creds", + CloudName: "openstack", + }, + Network: &infrav1.NetworkParam{ + ID: ptr.To(clusterNetworkID), + }, + DisableExternalNetwork: ptr.To(true), + DisableAPIServerFloatingIP: ptr.To(true), + APIServerFixedIP: ptr.To(fixedIP), + } + err := k8sClient.Create(ctx, testCluster) + Expect(err).To(BeNil()) + err = k8sClient.Create(ctx, capiCluster) + Expect(err).To(BeNil()) + + log := GinkgoLogr + clientScope, err := mockScopeFactory.NewClientScopeFromObject(ctx, k8sClient, nil, log, testCluster) + Expect(err).To(BeNil()) + scope := scope.NewWithLogger(clientScope, log) + + networkClientRecorder := mockScopeFactory.NetworkClient.EXPECT() + + // Fetch cluster network + networkClientRecorder.GetNetwork(clusterNetworkID).Return(&networks.Network{ + ID: clusterNetworkID, + Name: "cluster-network", + }, nil) + + // Fetching cluster subnets + networkClientRecorder.ListSubnet(subnets.ListOpts{ + NetworkID: clusterNetworkID, + }).Return([]subnets.Subnet{ + { + ID: clusterSubnetID, + Name: "cluster-subnet", + CIDR: "192.168.0.0/24", + }, + }, nil) + + err = reconcileNetworkComponents(scope, capiCluster, testCluster) + Expect(err).To(BeNil()) + + // Verify API endpoint was set with fixed IP + Expect(testCluster.Spec.ControlPlaneEndpoint).ToNot(BeNil()) + Expect(testCluster.Spec.ControlPlaneEndpoint.Host).To(Equal(fixedIP)) + Expect(testCluster.Spec.ControlPlaneEndpoint.Port).To(Equal(int32(6443))) + + // Verify conditions are set correctly + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.NetworkReadyCondition)).To(BeTrue()) + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.SecurityGroupsReadyCondition)).To(BeTrue()) + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.APIEndpointReadyCondition)).To(BeTrue()) + }) + + It("should set NetworkReadyCondition to False when network lookup fails", func() { + const clusterNetworkID = "6c90b532-7ba0-418a-a276-5ae55060b5b0" + + testCluster.SetName("network-lookup-failure") + testCluster.Spec = infrav1.OpenStackClusterSpec{ + IdentityRef: infrav1.OpenStackIdentityReference{ + Name: "test-creds", + CloudName: "openstack", + }, + Network: &infrav1.NetworkParam{ + ID: ptr.To(clusterNetworkID), + }, + DisableExternalNetwork: ptr.To(true), + DisableAPIServerFloatingIP: ptr.To(true), + APIServerFixedIP: ptr.To("192.168.0.10"), + } + err := k8sClient.Create(ctx, testCluster) + Expect(err).To(BeNil()) + err = k8sClient.Create(ctx, capiCluster) + Expect(err).To(BeNil()) + + log := GinkgoLogr + clientScope, err := mockScopeFactory.NewClientScopeFromObject(ctx, k8sClient, nil, log, testCluster) + Expect(err).To(BeNil()) + scope := scope.NewWithLogger(clientScope, log) + + networkClientRecorder := mockScopeFactory.NetworkClient.EXPECT() + + // Simulate network lookup failure + networkClientRecorder.GetNetwork(clusterNetworkID).Return(nil, fmt.Errorf("unable to get network")) + + err = reconcileNetworkComponents(scope, capiCluster, testCluster) + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("error fetching cluster network")) + + // Verify NetworkReadyCondition is set to False + Expect(v1beta1conditions.IsFalse(testCluster, infrav1.NetworkReadyCondition)).To(BeTrue()) + condition := v1beta1conditions.Get(testCluster, infrav1.NetworkReadyCondition) + Expect(condition).ToNot(BeNil()) + Expect(condition.Reason).To(Equal(infrav1.OpenStackErrorReason)) + Expect(condition.Severity).To(Equal(clusterv1beta1.ConditionSeverityError)) + Expect(condition.Message).To(ContainSubstring("Failed to find network")) + }) + + It("should set NetworkReadyCondition to False when subnet lookup fails", func() { + const clusterNetworkID = "6c90b532-7ba0-418a-a276-5ae55060b5b0" + + testCluster.SetName("subnet-lookup-failure") + testCluster.Spec = infrav1.OpenStackClusterSpec{ + IdentityRef: infrav1.OpenStackIdentityReference{ + Name: "test-creds", + CloudName: "openstack", + }, + Network: &infrav1.NetworkParam{ + ID: ptr.To(clusterNetworkID), + }, + DisableExternalNetwork: ptr.To(true), + DisableAPIServerFloatingIP: ptr.To(true), + APIServerFixedIP: ptr.To("192.168.0.10"), + } + err := k8sClient.Create(ctx, testCluster) + Expect(err).To(BeNil()) + err = k8sClient.Create(ctx, capiCluster) + Expect(err).To(BeNil()) + + log := GinkgoLogr + clientScope, err := mockScopeFactory.NewClientScopeFromObject(ctx, k8sClient, nil, log, testCluster) + Expect(err).To(BeNil()) + scope := scope.NewWithLogger(clientScope, log) + + networkClientRecorder := mockScopeFactory.NetworkClient.EXPECT() + + // Network lookup succeeds + networkClientRecorder.GetNetwork(clusterNetworkID).Return(&networks.Network{ + ID: clusterNetworkID, + Name: "cluster-network", + }, nil) + + // Subnet list lookup fails + networkClientRecorder.ListSubnet(subnets.ListOpts{ + NetworkID: clusterNetworkID, + }).Return(nil, fmt.Errorf("failed to list subnets")) + + err = reconcileNetworkComponents(scope, capiCluster, testCluster) + Expect(err).ToNot(BeNil()) + + // Verify NetworkReadyCondition is set to False + Expect(v1beta1conditions.IsFalse(testCluster, infrav1.NetworkReadyCondition)).To(BeTrue()) + condition := v1beta1conditions.Get(testCluster, infrav1.NetworkReadyCondition) + Expect(condition).ToNot(BeNil()) + Expect(condition.Reason).To(Equal(infrav1.OpenStackErrorReason)) + Expect(condition.Severity).To(Equal(clusterv1beta1.ConditionSeverityError)) + }) + + It("should set RouterReadyCondition to False when router lookup fails", func() { + const clusterNetworkID = "6c90b532-7ba0-418a-a276-5ae55060b5b0" + const clusterSubnetID = "cad5a91a-36de-4388-823b-b0cc82cadfdc" + const clusterRouterID = "a0e2b0a5-4d2f-4e8d-9a1c-6b3e7f8c9d0e" + + testCluster.SetName("router-lookup-failure") + testCluster.Spec = infrav1.OpenStackClusterSpec{ + IdentityRef: infrav1.OpenStackIdentityReference{ + Name: "test-creds", + CloudName: "openstack", + }, + Network: &infrav1.NetworkParam{ + ID: ptr.To(clusterNetworkID), + }, + Router: &infrav1.RouterParam{ + ID: ptr.To(clusterRouterID), + }, + DisableExternalNetwork: ptr.To(true), + DisableAPIServerFloatingIP: ptr.To(true), + APIServerFixedIP: ptr.To("192.168.0.10"), + } + err := k8sClient.Create(ctx, testCluster) + Expect(err).To(BeNil()) + err = k8sClient.Create(ctx, capiCluster) + Expect(err).To(BeNil()) + + log := GinkgoLogr + clientScope, err := mockScopeFactory.NewClientScopeFromObject(ctx, k8sClient, nil, log, testCluster) + Expect(err).To(BeNil()) + scope := scope.NewWithLogger(clientScope, log) + + networkClientRecorder := mockScopeFactory.NetworkClient.EXPECT() + + // Network lookup succeeds + networkClientRecorder.GetNetwork(clusterNetworkID).Return(&networks.Network{ + ID: clusterNetworkID, + Name: "cluster-network", + }, nil) + + // Subnet lookup succeeds + networkClientRecorder.ListSubnet(subnets.ListOpts{ + NetworkID: clusterNetworkID, + }).Return([]subnets.Subnet{ + { + ID: clusterSubnetID, + Name: "cluster-subnet", + CIDR: "192.168.0.0/24", + }, + }, nil) + + // Router lookup fails + networkClientRecorder.GetRouter(clusterRouterID).Return(nil, fmt.Errorf("unable to get router")) + + err = reconcileNetworkComponents(scope, capiCluster, testCluster) + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("error fetching cluster router")) + + // Verify RouterReadyCondition is set to False + Expect(v1beta1conditions.IsFalse(testCluster, infrav1.RouterReadyCondition)).To(BeTrue()) + condition := v1beta1conditions.Get(testCluster, infrav1.RouterReadyCondition) + Expect(condition).ToNot(BeNil()) + Expect(condition.Reason).To(Equal(infrav1.OpenStackErrorReason)) + Expect(condition.Severity).To(Equal(clusterv1beta1.ConditionSeverityError)) + Expect(condition.Message).To(ContainSubstring("Failed to find router")) + + // NetworkReadyCondition should still be True since network succeeded + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.NetworkReadyCondition)).To(BeTrue()) + }) + + It("should set SecurityGroupsReadyCondition to False when security group reconciliation fails", func() { + const clusterNetworkID = "6c90b532-7ba0-418a-a276-5ae55060b5b0" + const clusterSubnetID = "cad5a91a-36de-4388-823b-b0cc82cadfdc" + + testCluster.SetName("security-group-failure") + testCluster.Spec = infrav1.OpenStackClusterSpec{ + IdentityRef: infrav1.OpenStackIdentityReference{ + Name: "test-creds", + CloudName: "openstack", + }, + Network: &infrav1.NetworkParam{ + ID: ptr.To(clusterNetworkID), + }, + DisableExternalNetwork: ptr.To(true), + DisableAPIServerFloatingIP: ptr.To(true), + APIServerFixedIP: ptr.To("192.168.0.10"), + ManagedSecurityGroups: &infrav1.ManagedSecurityGroups{ + AllNodesSecurityGroupRules: []infrav1.SecurityGroupRuleSpec{ + { + Direction: "ingress", + Protocol: ptr.To("tcp"), + RemoteManagedGroups: []infrav1.ManagedSecurityGroupName{ + "worker", + }, + }, + }, + }, + } + err := k8sClient.Create(ctx, testCluster) + Expect(err).To(BeNil()) + err = k8sClient.Create(ctx, capiCluster) + Expect(err).To(BeNil()) + + log := GinkgoLogr + clientScope, err := mockScopeFactory.NewClientScopeFromObject(ctx, k8sClient, nil, log, testCluster) + Expect(err).To(BeNil()) + scope := scope.NewWithLogger(clientScope, log) + + networkClientRecorder := mockScopeFactory.NetworkClient.EXPECT() + + // Network lookup succeeds + networkClientRecorder.GetNetwork(clusterNetworkID).Return(&networks.Network{ + ID: clusterNetworkID, + Name: "cluster-network", + }, nil) + + // Subnet lookup succeeds + networkClientRecorder.ListSubnet(subnets.ListOpts{ + NetworkID: clusterNetworkID, + }).Return([]subnets.Subnet{ + { + ID: clusterSubnetID, + Name: "cluster-subnet", + CIDR: "192.168.0.0/24", + }, + }, nil) + + // Security group creation fails - this will trigger an error in getOrCreateSecurityGroup + networkClientRecorder.ListSecGroup(gomock.Any()).Return([]groups.SecGroup{}, nil).AnyTimes() + networkClientRecorder.CreateSecGroup(gomock.Any()).Return(nil, fmt.Errorf("quota exceeded")).AnyTimes() + + err = reconcileNetworkComponents(scope, capiCluster, testCluster) + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to reconcile security groups")) + + // Verify SecurityGroupsReadyCondition is set to False + Expect(v1beta1conditions.IsFalse(testCluster, infrav1.SecurityGroupsReadyCondition)).To(BeTrue()) + condition := v1beta1conditions.Get(testCluster, infrav1.SecurityGroupsReadyCondition) + Expect(condition).ToNot(BeNil()) + Expect(condition.Reason).To(Equal(infrav1.SecurityGroupReconcileFailedReason)) + Expect(condition.Severity).To(Equal(clusterv1beta1.ConditionSeverityError)) + Expect(condition.Message).To(ContainSubstring("Failed to reconcile security groups")) + + // NetworkReadyCondition should still be True since network succeeded + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.NetworkReadyCondition)).To(BeTrue()) + }) + + It("should set APIEndpointReadyCondition to False when floating IP creation fails", func() { + const externalNetworkID = "a42211a2-4d2c-426f-9413-830e4b4abbbc" + const clusterNetworkID = "6c90b532-7ba0-418a-a276-5ae55060b5b0" + const clusterSubnetID = "cad5a91a-36de-4388-823b-b0cc82cadfdc" + + testCluster.SetName("floating-ip-failure") + testCluster.Spec = infrav1.OpenStackClusterSpec{ + IdentityRef: infrav1.OpenStackIdentityReference{ + Name: "test-creds", + CloudName: "openstack", + }, + ExternalNetwork: &infrav1.NetworkParam{ + ID: ptr.To(externalNetworkID), + }, + Network: &infrav1.NetworkParam{ + ID: ptr.To(clusterNetworkID), + }, + // When DisableAPIServerFloatingIP is not set and external network is configured, + // a floating IP should be created for the API server + } + err := k8sClient.Create(ctx, testCluster) + Expect(err).To(BeNil()) + err = k8sClient.Create(ctx, capiCluster) + Expect(err).To(BeNil()) + + log := GinkgoLogr + clientScope, err := mockScopeFactory.NewClientScopeFromObject(ctx, k8sClient, nil, log, testCluster) + Expect(err).To(BeNil()) + scope := scope.NewWithLogger(clientScope, log) + + networkClientRecorder := mockScopeFactory.NetworkClient.EXPECT() + + // Fetch external network + networkClientRecorder.GetNetwork(externalNetworkID).Return(&networks.Network{ + ID: externalNetworkID, + Name: "external-network", + }, nil) + + // Fetch cluster network + networkClientRecorder.GetNetwork(clusterNetworkID).Return(&networks.Network{ + ID: clusterNetworkID, + Name: "cluster-network", + }, nil) + + // Fetching cluster subnets + networkClientRecorder.ListSubnet(subnets.ListOpts{ + NetworkID: clusterNetworkID, + }).Return([]subnets.Subnet{ + { + ID: clusterSubnetID, + Name: "cluster-subnet", + CIDR: "192.168.0.0/24", + }, + }, nil) + + // Mock floating IP creation failure + networkClientRecorder.CreateFloatingIP(gomock.Any()).Return(nil, fmt.Errorf("quota exceeded")) + + err = reconcileNetworkComponents(scope, capiCluster, testCluster) + Expect(err).ToNot(BeNil()) + + // Verify APIEndpointReadyCondition is set to False + Expect(v1beta1conditions.IsFalse(testCluster, infrav1.APIEndpointReadyCondition)).To(BeTrue()) + condition := v1beta1conditions.Get(testCluster, infrav1.APIEndpointReadyCondition) + Expect(condition).ToNot(BeNil()) + Expect(condition.Reason).To(Equal(infrav1.APIEndpointConfigFailedReason)) + Expect(condition.Severity).To(Equal(clusterv1beta1.ConditionSeverityError)) + Expect(condition.Message).To(ContainSubstring("Failed to reconcile control plane endpoint")) + + // NetworkReadyCondition and SecurityGroupsReadyCondition should still be True + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.NetworkReadyCondition)).To(BeTrue()) + Expect(v1beta1conditions.IsTrue(testCluster, infrav1.SecurityGroupsReadyCondition)).To(BeTrue()) }) }) diff --git a/docs/book/src/api/v1beta1/api.md b/docs/book/src/api/v1beta1/api.md index 0eb377d117..ce0334eca1 100644 --- a/docs/book/src/api/v1beta1/api.md +++ b/docs/book/src/api/v1beta1/api.md @@ -1565,6 +1565,38 @@ availability zone.
++(Appears on: +OpenStackClusterStatus) +
++
ClusterInitialization represents the initialization status of the cluster.
+ +| Field | +Description | +
|---|---|
+provisioned+ +bool + + |
+
+(Optional)
+ Provisioned is set to true when the initial provisioning of the cluster infrastructure is completed. +The value of this field is never updated after provisioning is completed. + |
+
@@ -2672,6 +2704,22 @@ bool
Ready is true when the cluster infrastructure is ready.
+Deprecated: This field is deprecated and will be removed in a future API version. +Use status.conditions to determine the ready state of the cluster.
+initializationInitialization contains information about the initialization status of the cluster.
Any transient errors that occur during the reconciliation of OpenStackClusters can be added as events to the OpenStackCluster object and/or logged in the controller’s output.
+Deprecated: This field is deprecated and will be removed in a future API version. +Use status.conditions to report failures.
Any transient errors that occur during the reconciliation of OpenStackClusters can be added as events to the OpenStackCluster object and/or logged in the controller’s output.
+Deprecated: This field is deprecated and will be removed in a future API version. +Use status.conditions to report failures.
+ +conditionsConditions defines current service state of the OpenStackCluster. +This field surfaces into Cluster’s status.conditions[InfrastructureReady] condition. +The Ready condition must surface issues during the entire lifecycle of the OpenStackCluster +(both during initial provisioning and after the initial provisioning is completed).