diff --git a/PROJECT b/PROJECT index 44c9df3c2c..71ec9e3afe 100644 --- a/PROJECT +++ b/PROJECT @@ -58,3 +58,6 @@ resources: - group: infrastructure version: v1beta2 kind: AWSManagedCluster +- group: infrastructure + kind: ROSANetwork + version: v1beta2 diff --git a/config/crd/bases/controlplane.cluster.x-k8s.io_rosacontrolplanes.yaml b/config/crd/bases/controlplane.cluster.x-k8s.io_rosacontrolplanes.yaml index d9c269205a..ea50a597e3 100644 --- a/config/crd/bases/controlplane.cluster.x-k8s.io_rosacontrolplanes.yaml +++ b/config/crd/bases/controlplane.cluster.x-k8s.io_rosacontrolplanes.yaml @@ -776,6 +776,22 @@ spec: x-kubernetes-validations: - message: rosaClusterName is immutable rule: self == oldSelf + rosaNetworkRef: + description: |- + ROSANetworkRef references ROSANetwork custom resource that contains the networking infrastructure + for Rosa HCP cluster + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic subnets: description: |- The Subnet IDs to use when installing the cluster. @@ -809,14 +825,12 @@ spec: to worker instances. type: string required: - - availabilityZones - channelGroup - installerRoleARN - oidcID - region - rolesRef - rosaClusterName - - subnets - supportRoleARN - version - versionGate diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_rosanetworks.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_rosanetworks.yaml new file mode 100644 index 0000000000..457ae8a477 --- /dev/null +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_rosanetworks.yaml @@ -0,0 +1,218 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: rosanetworks.infrastructure.cluster.x-k8s.io +spec: + group: infrastructure.cluster.x-k8s.io + names: + categories: + - cluster-api + kind: ROSANetwork + listKind: ROSANetworkList + plural: rosanetworks + shortNames: + - rosanet + singular: rosanetwork + scope: Namespaced + versions: + - name: v1beta2 + schema: + openAPIV3Schema: + description: ROSANetwork is the schema for the rosanetworks API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ROSANetworkSpec defines the desired state of ROSANetwork + properties: + availabilityZoneCount: + default: 1 + description: |- + The number of availability zones to be used for creation of the network infrastructure. + You can specify anything between one and four, depending on the chosen AWS region. + type: integer + availabilityZones: + description: |- + The list of availability zones to be used for creation of the network infrastructure. + You can specify anything between one and four valid availability zones from a given region. + Should you specify both the availabilityZoneCount and availabilityZones, the list of availability zones takes preference. + items: + type: string + type: array + cidrBlock: + description: CIDR block to be used for the VPC + format: cidr + type: string + identityRef: + description: |- + IdentityRef is a reference to an identity to be used when reconciling rosa network. + If no identity is specified, the default identity for this controller will be used. + properties: + kind: + description: Kind of the identity. + enum: + - AWSClusterControllerIdentity + - AWSClusterRoleIdentity + - AWSClusterStaticIdentity + type: string + name: + description: Name of the identity. + minLength: 1 + type: string + required: + - kind + - name + type: object + region: + description: The AWS region in which the components of ROSA network + infrastruture are to be crated + type: string + stackName: + description: The name of the cloudformation stack under which the + network infrastructure would be created + type: string + stackTags: + additionalProperties: + type: string + description: |- + StackTags is an optional set of tags to add to the created cloudformation stack. + The stack tags will then be automatically applied to the supported AWS resources (VPC, subnets, ...). + type: object + required: + - cidrBlock + - region + - stackName + type: object + status: + description: ROSANetworkStatus defines the observed state of ROSANetwork + properties: + conditions: + description: Conditions specifies the conditions for ROSANetwork + 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 + resources: + description: Resources created in the cloudformation stack + items: + description: CFResource groups information pertaining to a resource + created as a part of a cloudformation stack + properties: + logicalId: + description: LogicalResourceID of the created resource. + type: string + physicalId: + description: PhysicalResourceID of the created resource. + type: string + reason: + description: Message pertaining to the status of the resource + type: string + resource: + description: 'Type of the created resource: AWS::EC2::VPC, AWS::EC2::Subnet, + ...' + type: string + status: + description: 'Status of the resource: CREATE_IN_PROGRESS, CREATE_COMPLETE, + ...' + type: string + required: + - logicalId + - physicalId + - reason + - resource + - status + type: object + type: array + subnets: + description: Array of created private, public subnets and availability + zones, grouped by availability zones + items: + description: ROSANetworkSubnet groups public and private subnet + and the availability zone in which the two subnets got created + properties: + availabilityZone: + description: Availability zone of the subnet pair, for example + us-west-2a + type: string + privateSubnet: + description: ID of the private subnet, for example subnet-07a20d6c41af2b725 + type: string + publicSubnet: + description: ID of the public subnet, for example subnet-0f7e49a3ce68ff338 + type: string + required: + - availabilityZone + - privateSubnet + - publicSubnet + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index c3f6177556..5431616d2b 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -26,6 +26,7 @@ resources: - bases/controlplane.cluster.x-k8s.io_rosacontrolplanes.yaml - bases/infrastructure.cluster.x-k8s.io_rosaclusters.yaml - bases/infrastructure.cluster.x-k8s.io_rosamachinepools.yaml +- bases/infrastructure.cluster.x-k8s.io_rosanetworks.yaml # +kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: @@ -57,6 +58,7 @@ patchesStrategicMerge: - patches/cainjection_in_awsmanagedclustertemplates.yaml - patches/cainjection_in_eksconfigs.yaml - patches/cainjection_in_eksconfigtemplates.yaml +- patches/cainjection_in_rosanetworks.yaml # +kubebuilder:scaffold:crdkustomizecainjectionpatch # [LABEL] To enable label, uncomment all the sections with [LABEL] prefix. diff --git a/config/crd/patches/cainjection_in_rosanetworks.yaml b/config/crd/patches/cainjection_in_rosanetworks.yaml new file mode 100644 index 0000000000..91ac2be238 --- /dev/null +++ b/config/crd/patches/cainjection_in_rosanetworks.yaml @@ -0,0 +1,8 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +# CRD conversion requires k8s 1.13 or later. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: rosanetworks.infrastructure.cluster.x-k8s.io diff --git a/config/crd/patches/webhook_in_rosanetworks.yaml b/config/crd/patches/webhook_in_rosanetworks.yaml new file mode 100644 index 0000000000..389e11c4a3 --- /dev/null +++ b/config/crd/patches/webhook_in_rosanetworks.yaml @@ -0,0 +1,14 @@ +# The following patch enables conversion webhook for CRD +# CRD conversion requires k8s 1.13 or later. +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: rosanetworks.infrastructure.cluster.x-k8s.io +spec: + conversion: + strategy: Webhook + webhookClientConfig: + service: + namespace: system + name: webhook-service + path: /convert diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index cb04a602d7..0d1adb3df6 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -176,6 +176,7 @@ rules: - awsfargateprofiles/status - rosaclusters/status - rosamachinepools/status + - rosanetworks/status verbs: - get - patch @@ -197,6 +198,7 @@ rules: - infrastructure.cluster.x-k8s.io resources: - awsmachines + - rosanetworks verbs: - create - delete diff --git a/controlplane/rosa/api/v1beta2/rosacontrolplane_types.go b/controlplane/rosa/api/v1beta2/rosacontrolplane_types.go index 95d06281ff..5d133bfedc 100644 --- a/controlplane/rosa/api/v1beta2/rosacontrolplane_types.go +++ b/controlplane/rosa/api/v1beta2/rosacontrolplane_types.go @@ -95,12 +95,14 @@ type RosaControlPlaneSpec struct { //nolint: maligned // The Subnet IDs to use when installing the cluster. // SubnetIDs should come in pairs; two per availability zone, one private and one public. - Subnets []string `json:"subnets"` + // +optional + Subnets []string `json:"subnets,omitempty"` // AvailabilityZones describe AWS AvailabilityZones of the worker nodes. // should match the AvailabilityZones of the provided Subnets. // a machinepool will be created for each availabilityZone. - AvailabilityZones []string `json:"availabilityZones"` + // +optional + AvailabilityZones []string `json:"availabilityZones,omitempty"` // The AWS Region the cluster lives in. Region string `json:"region"` @@ -231,6 +233,11 @@ type RosaControlPlaneSpec struct { //nolint: maligned // ClusterRegistryConfig represents registry config used with the cluster. // +optional ClusterRegistryConfig *RegistryConfig `json:"clusterRegistryConfig,omitempty"` + + // ROSANetworkRef references ROSANetwork custom resource that contains the networking infrastructure + // for Rosa HCP cluster + // +optional + ROSANetworkRef *corev1.LocalObjectReference `json:"rosaNetworkRef,omitempty"` } // RegistryConfig for ROSA-HCP cluster diff --git a/controlplane/rosa/api/v1beta2/rosacontrolplane_webhook.go b/controlplane/rosa/api/v1beta2/rosacontrolplane_webhook.go index 56071a878e..c7812478ec 100644 --- a/controlplane/rosa/api/v1beta2/rosacontrolplane_webhook.go +++ b/controlplane/rosa/api/v1beta2/rosacontrolplane_webhook.go @@ -61,6 +61,10 @@ func (*rosaControlPlaneWebhook) ValidateCreate(_ context.Context, obj runtime.Ob allErrs = append(allErrs, r.validateNetwork()...) allErrs = append(allErrs, r.Spec.AdditionalTags.Validate()...) + if err := r.validateROSANetwork(); err != nil { + allErrs = append(allErrs, err) + } + if len(allErrs) == 0 { return nil, nil } @@ -179,6 +183,23 @@ func (r *ROSAControlPlane) validateExternalAuthProviders() *field.Error { return nil } +func (r *ROSAControlPlane) validateROSANetwork() *field.Error { + if r.Spec.ROSANetworkRef != nil { + if r.Spec.Subnets != nil { + return field.Forbidden(field.NewPath("spec.rosaNetworkRef"), "spec.subnets and spec.rosaNetworkRef are mutually exclusive") + } + if r.Spec.AvailabilityZones != nil { + return field.Forbidden(field.NewPath("spec.rosaNetworkRef"), "spec.availabilityZones and spec.rosaNetworkRef are mutually exclusive") + } + } + + if r.Spec.ROSANetworkRef == nil && (r.Spec.Subnets == nil || r.Spec.AvailabilityZones == nil) { + return field.Required(field.NewPath("spec.subnets"), "spec.subnets and spec.availabilityZones cannot be empty when spec.rosaNetworkRef is unspecified") + } + + return nil +} + // Default implements admission.Defaulter. func (*rosaControlPlaneWebhook) Default(_ context.Context, obj runtime.Object) error { r, ok := obj.(*ROSAControlPlane) diff --git a/controlplane/rosa/api/v1beta2/zz_generated.deepcopy.go b/controlplane/rosa/api/v1beta2/zz_generated.deepcopy.go index 3e4dfdf8cf..749a65b5d5 100644 --- a/controlplane/rosa/api/v1beta2/zz_generated.deepcopy.go +++ b/controlplane/rosa/api/v1beta2/zz_generated.deepcopy.go @@ -348,6 +348,11 @@ func (in *RosaControlPlaneSpec) DeepCopyInto(out *RosaControlPlaneSpec) { *out = new(RegistryConfig) (*in).DeepCopyInto(*out) } + if in.ROSANetworkRef != nil { + in, out := &in.ROSANetworkRef, &out.ROSANetworkRef + *out = new(v1.LocalObjectReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RosaControlPlaneSpec. diff --git a/controlplane/rosa/controllers/rosacontrolplane_controller.go b/controlplane/rosa/controllers/rosacontrolplane_controller.go index 37143bc3f9..0ffa00e479 100644 --- a/controlplane/rosa/controllers/rosacontrolplane_controller.go +++ b/controlplane/rosa/controllers/rosacontrolplane_controller.go @@ -314,7 +314,27 @@ func (r *ROSAControlPlaneReconciler) reconcileNormal(ctx context.Context, rosaSc return ctrl.Result{RequeueAfter: time.Second * 60}, nil } - ocmClusterSpec, err := buildOCMClusterSpec(rosaScope.ControlPlane.Spec, creator) + rosaNet := &expinfrav1.ROSANetwork{} + // Does the control plane reference ROSANetwork? + if rosaScope.ControlPlane.Spec.ROSANetworkRef != nil { + objKey := client.ObjectKey{ + Name: rosaScope.ControlPlane.Spec.ROSANetworkRef.Name, + Namespace: rosaScope.ControlPlane.Namespace, + } + + err := rosaScope.Client.Get(ctx, objKey, rosaNet) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to fetch ROSANetwork: %w", err) + } + + // Is the referenced ROSANetwork ready yet? + if !conditions.IsTrue(rosaNet, expinfrav1.ROSANetworkReadyCondition) { + rosaScope.Info(fmt.Sprintf("referenced ROSANetwork %s is not ready", rosaNet.Name)) + return ctrl.Result{RequeueAfter: time.Minute}, nil + } + } + + ocmClusterSpec, err := buildOCMClusterSpec(rosaScope.ControlPlane.Spec, rosaNet, creator) if err != nil { return ctrl.Result{}, err } @@ -915,12 +935,25 @@ func validateControlPlaneSpec(ocmClient rosa.OCMClient, rosaScope *scope.ROSACon return "", nil } -func buildOCMClusterSpec(controlPlaneSpec rosacontrolplanev1.RosaControlPlaneSpec, creator *rosaaws.Creator) (ocm.Spec, error) { +func buildOCMClusterSpec(controlPlaneSpec rosacontrolplanev1.RosaControlPlaneSpec, rosaNet *expinfrav1.ROSANetwork, creator *rosaaws.Creator) (ocm.Spec, error) { billingAccount := controlPlaneSpec.BillingAccount if billingAccount == "" { billingAccount = creator.AccountID } + var subnetIDs []string + var availabilityZones []string + + if controlPlaneSpec.ROSANetworkRef == nil { + subnetIDs = controlPlaneSpec.Subnets + availabilityZones = controlPlaneSpec.AvailabilityZones + } else { + for _, v := range rosaNet.Status.Subnets { + subnetIDs = append(subnetIDs, v.PublicSubnet, v.PrivateSubnet) + availabilityZones = append(availabilityZones, v.AvailabilityZone) + } + } + ocmClusterSpec := ocm.Spec{ DryRun: ptr.To(false), Name: controlPlaneSpec.RosaClusterName, @@ -932,12 +965,12 @@ func buildOCMClusterSpec(controlPlaneSpec rosacontrolplanev1.RosaControlPlaneSpe DisableWorkloadMonitoring: ptr.To(true), DefaultIngress: ocm.NewDefaultIngressSpec(), // n.b. this is a no-op when it's set to the default value ComputeMachineType: controlPlaneSpec.DefaultMachinePoolSpec.InstanceType, - AvailabilityZones: controlPlaneSpec.AvailabilityZones, + AvailabilityZones: availabilityZones, Tags: controlPlaneSpec.AdditionalTags, EtcdEncryption: controlPlaneSpec.EtcdEncryptionKMSARN != "", EtcdEncryptionKMSArn: controlPlaneSpec.EtcdEncryptionKMSARN, - SubnetIds: controlPlaneSpec.Subnets, + SubnetIds: subnetIDs, IsSTS: true, RoleARN: controlPlaneSpec.InstallerRoleARN, SupportRoleARN: controlPlaneSpec.SupportRoleARN, @@ -998,8 +1031,8 @@ func buildOCMClusterSpec(controlPlaneSpec rosacontrolplanev1.RosaControlPlaneSpe ocmClusterSpec.Autoscaling = true ocmClusterSpec.MaxReplicas = computeAutoscaling.MaxReplicas ocmClusterSpec.MinReplicas = computeAutoscaling.MinReplicas - } else if len(controlPlaneSpec.AvailabilityZones) > 1 { - ocmClusterSpec.ComputeNodes = len(controlPlaneSpec.AvailabilityZones) + } else if len(ocmClusterSpec.AvailabilityZones) > 1 { + ocmClusterSpec.ComputeNodes = len(ocmClusterSpec.AvailabilityZones) } if controlPlaneSpec.ProvisionShardID != "" { diff --git a/exp/api/v1beta2/conditions_consts.go b/exp/api/v1beta2/conditions_consts.go index 0f3d8675ca..a6a3f696f4 100644 --- a/exp/api/v1beta2/conditions_consts.go +++ b/exp/api/v1beta2/conditions_consts.go @@ -129,3 +129,23 @@ const ( // RosaMachinePoolReconciliationFailedReason used to report failures while reconciling ROSAMachinePool. RosaMachinePoolReconciliationFailedReason = "ReconciliationFailed" ) + +const ( + // ROSANetworkReadyCondition condition reports on the successful reconciliation of ROSANetwork. + ROSANetworkReadyCondition clusterv1.ConditionType = "ROSANetworkReady" + + // ROSANetworkCreatingReason used when ROSANetwork is being created. + ROSANetworkCreatingReason = "Creating" + + // ROSANetworkCreatedReason used when ROSANetwork is created. + ROSANetworkCreatedReason = "Created" + + // ROSANetworkFailedReason used when rosaNetwork creation failed. + ROSANetworkFailedReason = "Failed" + + // ROSANetworkDeletingReason used when ROSANetwork is being deleted. + ROSANetworkDeletingReason = "Deleting" + + // ROSANetworkDeletionFailedReason used to report failures while deleting ROSANetwork. + ROSANetworkDeletionFailedReason = "DeletionFailed" +) diff --git a/exp/api/v1beta2/rosanetwork_types.go b/exp/api/v1beta2/rosanetwork_types.go new file mode 100644 index 0000000000..b6a6d3634f --- /dev/null +++ b/exp/api/v1beta2/rosanetwork_types.go @@ -0,0 +1,148 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + infrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// ROSANetworkFinalizer allows the controller to clean up resources on delete. +const ROSANetworkFinalizer = "rosanetwork.infrastructure.cluster.x-k8s.io" + +// ROSANetworkSpec defines the desired state of ROSANetwork +type ROSANetworkSpec struct { + // The name of the cloudformation stack under which the network infrastructure would be created + // +immutable + StackName string `json:"stackName"` + + // The AWS region in which the components of ROSA network infrastruture are to be crated + // +immutable + Region string `json:"region"` + + // The number of availability zones to be used for creation of the network infrastructure. + // You can specify anything between one and four, depending on the chosen AWS region. + // +kubebuilder:default=1 + // +optional + // +immutable + AvailabilityZoneCount int `json:"availabilityZoneCount"` + + // The list of availability zones to be used for creation of the network infrastructure. + // You can specify anything between one and four valid availability zones from a given region. + // Should you specify both the availabilityZoneCount and availabilityZones, the list of availability zones takes preference. + // +optional + // +immutable + AvailabilityZones []string `json:"availabilityZones"` + + // CIDR block to be used for the VPC + // +kubebuilder:validation:Format=cidr + // +immutable + CIDRBlock string `json:"cidrBlock"` + + // IdentityRef is a reference to an identity to be used when reconciling rosa network. + // If no identity is specified, the default identity for this controller will be used. + // + // +optional + IdentityRef *infrav1.AWSIdentityReference `json:"identityRef,omitempty"` + + // StackTags is an optional set of tags to add to the created cloudformation stack. + // The stack tags will then be automatically applied to the supported AWS resources (VPC, subnets, ...). + // + // +optional + StackTags Tags `json:"stackTags,omitempty"` +} + +// ROSANetworkSubnet groups public and private subnet and the availability zone in which the two subnets got created +type ROSANetworkSubnet struct { + // Availability zone of the subnet pair, for example us-west-2a + AvailabilityZone string `json:"availabilityZone"` + + // ID of the public subnet, for example subnet-0f7e49a3ce68ff338 + PublicSubnet string `json:"publicSubnet"` + + // ID of the private subnet, for example subnet-07a20d6c41af2b725 + PrivateSubnet string `json:"privateSubnet"` +} + +// CFResource groups information pertaining to a resource created as a part of a cloudformation stack +type CFResource struct { + // Type of the created resource: AWS::EC2::VPC, AWS::EC2::Subnet, ... + ResourceType string `json:"resource"` + + // LogicalResourceID of the created resource. + LogicalID string `json:"logicalId"` + + // PhysicalResourceID of the created resource. + PhysicalID string `json:"physicalId"` + + // Status of the resource: CREATE_IN_PROGRESS, CREATE_COMPLETE, ... + Status string `json:"status"` + + // Message pertaining to the status of the resource + Reason string `json:"reason"` +} + +// ROSANetworkStatus defines the observed state of ROSANetwork +type ROSANetworkStatus struct { + // Array of created private, public subnets and availability zones, grouped by availability zones + Subnets []ROSANetworkSubnet `json:"subnets,omitempty"` + + // Resources created in the cloudformation stack + Resources []CFResource `json:"resources,omitempty"` + + // Conditions specifies the conditions for ROSANetwork + Conditions clusterv1.Conditions `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=rosanetworks,shortName=rosanet,scope=Namespaced,categories=cluster-api +// +kubebuilder:storageversion +// +kubebuilder:subresource:status + +// ROSANetwork is the schema for the rosanetworks API +type ROSANetwork struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ROSANetworkSpec `json:"spec,omitempty"` + Status ROSANetworkStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ROSANetworkList contains a list of ROSANetwork +type ROSANetworkList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ROSANetwork `json:"items"` +} + +// GetConditions returns the observations of the operational state of the ROSANetwork resource. +func (r *ROSANetwork) GetConditions() clusterv1.Conditions { + return r.Status.Conditions +} + +// SetConditions sets the underlying service state of the ROSANetwork to the predescribed clusterv1.Conditions. +func (r *ROSANetwork) SetConditions(conditions clusterv1.Conditions) { + r.Status.Conditions = conditions +} + +func init() { + SchemeBuilder.Register(&ROSANetwork{}, &ROSANetworkList{}) +} diff --git a/exp/api/v1beta2/zz_generated.deepcopy.go b/exp/api/v1beta2/zz_generated.deepcopy.go index 6885eb4c64..bbdf512cb6 100644 --- a/exp/api/v1beta2/zz_generated.deepcopy.go +++ b/exp/api/v1beta2/zz_generated.deepcopy.go @@ -688,6 +688,21 @@ func (in *BlockDeviceMapping) DeepCopy() *BlockDeviceMapping { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CFResource) DeepCopyInto(out *CFResource) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CFResource. +func (in *CFResource) DeepCopy() *CFResource { + if in == nil { + return nil + } + out := new(CFResource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EBS) DeepCopyInto(out *EBS) { *out = *in @@ -1129,6 +1144,144 @@ func (in *ROSAMachinePoolList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ROSANetwork) DeepCopyInto(out *ROSANetwork) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ROSANetwork. +func (in *ROSANetwork) DeepCopy() *ROSANetwork { + if in == nil { + return nil + } + out := new(ROSANetwork) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ROSANetwork) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ROSANetworkList) DeepCopyInto(out *ROSANetworkList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ROSANetwork, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ROSANetworkList. +func (in *ROSANetworkList) DeepCopy() *ROSANetworkList { + if in == nil { + return nil + } + out := new(ROSANetworkList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ROSANetworkList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ROSANetworkSpec) DeepCopyInto(out *ROSANetworkSpec) { + *out = *in + if in.AvailabilityZones != nil { + in, out := &in.AvailabilityZones, &out.AvailabilityZones + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.IdentityRef != nil { + in, out := &in.IdentityRef, &out.IdentityRef + *out = new(apiv1beta2.AWSIdentityReference) + **out = **in + } + if in.StackTags != nil { + in, out := &in.StackTags, &out.StackTags + *out = make(Tags, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ROSANetworkSpec. +func (in *ROSANetworkSpec) DeepCopy() *ROSANetworkSpec { + if in == nil { + return nil + } + out := new(ROSANetworkSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ROSANetworkStatus) DeepCopyInto(out *ROSANetworkStatus) { + *out = *in + if in.Subnets != nil { + in, out := &in.Subnets, &out.Subnets + *out = make([]ROSANetworkSubnet, len(*in)) + copy(*out, *in) + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]CFResource, len(*in)) + copy(*out, *in) + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(v1beta1.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ROSANetworkStatus. +func (in *ROSANetworkStatus) DeepCopy() *ROSANetworkStatus { + if in == nil { + return nil + } + out := new(ROSANetworkStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ROSANetworkSubnet) DeepCopyInto(out *ROSANetworkSubnet) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ROSANetworkSubnet. +func (in *ROSANetworkSubnet) DeepCopy() *ROSANetworkSubnet { + if in == nil { + return nil + } + out := new(ROSANetworkSubnet) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RefreshPreferences) DeepCopyInto(out *RefreshPreferences) { *out = *in diff --git a/exp/controllers/rosanetwork_controller.go b/exp/controllers/rosanetwork_controller.go new file mode 100644 index 0000000000..df12fde6a2 --- /dev/null +++ b/exp/controllers/rosanetwork_controller.go @@ -0,0 +1,309 @@ +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + "strconv" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + cloudformationtypes "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" + "github.com/aws/smithy-go" + "github.com/go-logr/logr" + rosaCFNetwork "github.com/openshift/rosa/cmd/create/network" + rosaAWSClient "github.com/openshift/rosa/pkg/aws" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + expinfrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/exp/api/v1beta2" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/scope" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/logger" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/conditions" +) + +// ROSANetworkReconciler reconciles a ROSANetwork object. +type ROSANetworkReconciler struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme + awsClient rosaAWSClient.Client + cfStack *cloudformationtypes.Stack +} + +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=rosanetworks,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=rosanetworks/status,verbs=get;update;patch + +func (r *ROSANetworkReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, reterr error) { + log := logger.FromContext(ctx) + + // Get the rosanetwork instance + rosaNetwork := &expinfrav1.ROSANetwork{} + if err := r.Client.Get(ctx, req.NamespacedName, rosaNetwork); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + log.Info("error getting ROSANetwork: %w", err) + return ctrl.Result{Requeue: true}, nil + } + + rosaNetworkScope, err := scope.NewROSANetworkScope(scope.ROSANetworkScopeParams{ + Client: r.Client, + ROSANetwork: rosaNetwork, + ControllerName: "rosanetwork", + Logger: log, + }) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to create rosanetwork scope: %w", err) + } + + // Create a new AWS/CloudFormation Client using the session cache + if r.awsClient == nil { + session := rosaNetworkScope.Session() + logger := rosaNetworkScope.Logger.GetLogger() + awsClient, err := rosaAWSClient.NewClient(). + CapaLogger(&logger). + ExternalConfig(&session). + Build() + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to create AWS Client: %w", err) + } + r.awsClient = awsClient + } + + // Try to fetch CF stack with a given name + r.cfStack, err = r.awsClient.GetCFStack(ctx, rosaNetworkScope.ROSANetwork.Spec.StackName) + if err != nil { + var apiErr smithy.APIError // in case the stack does not exist, AWS returns ValidationError + if errors.As(err, &apiErr) && apiErr.ErrorCode() == "ValidationError" { + r.cfStack = nil + } else { + return ctrl.Result{}, fmt.Errorf("error fetching CF stack details: %w", err) + } + } + + // Always close the scope + defer func() { + if err := rosaNetworkScope.PatchObject(); err != nil { + reterr = errors.Join(reterr, err) + } + }() + + if !rosaNetwork.ObjectMeta.DeletionTimestamp.IsZero() { + // Handle deletion reconciliation loop. + return r.reconcileDelete(ctx, rosaNetworkScope) + } + + // Handle normal reconciliation loop. + return r.reconcileNormal(ctx, rosaNetworkScope) +} + +func (r *ROSANetworkReconciler) reconcileNormal(ctx context.Context, rosaNetScope *scope.ROSANetworkScope) (res ctrl.Result, reterr error) { + rosaNetScope.Info("Reconciling ROSANetwork") + + if controllerutil.AddFinalizer(rosaNetScope.ROSANetwork, expinfrav1.ROSANetworkFinalizer) { + if err := rosaNetScope.PatchObject(); err != nil { + return ctrl.Result{}, err + } + } + + if r.cfStack == nil { // The CF stack does not exist yet + templateBody := string(rosaCFNetwork.CloudFormationTemplateFile) + cfParams := map[string]string{ + "AvailabilityZoneCount": strconv.Itoa(rosaNetScope.ROSANetwork.Spec.AvailabilityZoneCount), + "Region": rosaNetScope.ROSANetwork.Spec.Region, + "Name": rosaNetScope.ROSANetwork.Spec.StackName, + "VpcCidr": rosaNetScope.ROSANetwork.Spec.CIDRBlock, + } + // Explicitly specified AZs + for i, zone := range rosaNetScope.ROSANetwork.Spec.AvailabilityZones { + cfParams[fmt.Sprintf("AZ%d", i)] = zone + } + + // Call the AWS CF stack create API + _, err := r.awsClient.CreateStackWithParamsTags(ctx, templateBody, rosaNetScope.ROSANetwork.Spec.StackName, cfParams, rosaNetScope.ROSANetwork.Spec.StackTags) + if err != nil { + conditions.MarkFalse(rosaNetScope.ROSANetwork, + expinfrav1.ROSANetworkReadyCondition, + expinfrav1.ROSANetworkFailedReason, + clusterv1.ConditionSeverityError, + "%s", + err.Error()) + return ctrl.Result{}, fmt.Errorf("failed to start CF stack creation: %w", err) + } + conditions.MarkFalse(rosaNetScope.ROSANetwork, + expinfrav1.ROSANetworkReadyCondition, + expinfrav1.ROSANetworkCreatingReason, + clusterv1.ConditionSeverityInfo, + "") + return ctrl.Result{}, nil + } + // The cloudformation stack already exists + if err := r.updateROSANetworkResources(ctx, rosaNetScope.ROSANetwork); err != nil { + rosaNetScope.Info("error fetching CF stack resources: %w", err) + return ctrl.Result{RequeueAfter: time.Second * 60}, nil + } + + switch r.cfStack.StackStatus { + case cloudformationtypes.StackStatusCreateInProgress: // Create in progress + // Set the reason of false ROSANetworkReadyCondition to Creating + conditions.MarkFalse(rosaNetScope.ROSANetwork, + expinfrav1.ROSANetworkReadyCondition, + expinfrav1.ROSANetworkCreatingReason, + clusterv1.ConditionSeverityInfo, + "") + return ctrl.Result{RequeueAfter: time.Second * 60}, nil + case cloudformationtypes.StackStatusCreateComplete: // Create complete + if err := r.parseSubnets(rosaNetScope.ROSANetwork); err != nil { + return ctrl.Result{}, fmt.Errorf("parsing stack subnets failed: %w", err) + } + + // Set the reason of true ROSANetworkReadyCondition to Created + // We have to use conditions.Set(), since conditions.MarkTrue() does not support setting reason + conditions.Set(rosaNetScope.ROSANetwork, + &clusterv1.Condition{ + Type: expinfrav1.ROSANetworkReadyCondition, + Status: corev1.ConditionTrue, + Reason: expinfrav1.ROSANetworkCreatedReason, + Severity: clusterv1.ConditionSeverityInfo, + }) + return ctrl.Result{}, nil + case cloudformationtypes.StackStatusCreateFailed: // Create failed + // Set the reason of false ROSANetworkReadyCondition to Failed + conditions.MarkFalse(rosaNetScope.ROSANetwork, + expinfrav1.ROSANetworkReadyCondition, + expinfrav1.ROSANetworkFailedReason, + clusterv1.ConditionSeverityError, + "") + return ctrl.Result{}, fmt.Errorf("cloudformation stack %s creation failed, see the stack resources for more information", *r.cfStack.StackName) + } + + return ctrl.Result{}, nil +} + +func (r *ROSANetworkReconciler) reconcileDelete(ctx context.Context, rosaNetScope *scope.ROSANetworkScope) (res ctrl.Result, reterr error) { + rosaNetScope.Info("Reconciling ROSANetwork delete") + + if r.cfStack != nil { // The CF stack still exists + if err := r.updateROSANetworkResources(ctx, rosaNetScope.ROSANetwork); err != nil { + rosaNetScope.Info("error fetching CF stack resources: %w", err) + return ctrl.Result{RequeueAfter: time.Second * 60}, nil + } + + switch r.cfStack.StackStatus { + case cloudformationtypes.StackStatusDeleteInProgress: // Deletion in progress + return ctrl.Result{RequeueAfter: time.Second * 60}, nil + case cloudformationtypes.StackStatusDeleteFailed: // Deletion failed + conditions.MarkFalse(rosaNetScope.ROSANetwork, + expinfrav1.ROSANetworkReadyCondition, + expinfrav1.ROSANetworkDeletionFailedReason, + clusterv1.ConditionSeverityError, + "") + return ctrl.Result{}, fmt.Errorf("CF stack deletion failed") + default: // All the other states + err := r.awsClient.DeleteCFStack(ctx, rosaNetScope.ROSANetwork.Spec.StackName) + if err != nil { + conditions.MarkFalse(rosaNetScope.ROSANetwork, + expinfrav1.ROSANetworkReadyCondition, + expinfrav1.ROSANetworkDeletionFailedReason, + clusterv1.ConditionSeverityError, + "%s", + err.Error()) + return ctrl.Result{}, fmt.Errorf("failed to start CF stack deletion: %w", err) + } + conditions.MarkFalse(rosaNetScope.ROSANetwork, + expinfrav1.ROSANetworkReadyCondition, + expinfrav1.ROSANetworkDeletingReason, + clusterv1.ConditionSeverityInfo, + "") + return ctrl.Result{RequeueAfter: time.Second * 60}, nil + } + } else { + controllerutil.RemoveFinalizer(rosaNetScope.ROSANetwork, expinfrav1.ROSANetworkFinalizer) + } + + return ctrl.Result{}, nil +} + +func (r *ROSANetworkReconciler) updateROSANetworkResources(ctx context.Context, rosaNet *expinfrav1.ROSANetwork) error { + resources, err := r.awsClient.DescribeCFStackResources(ctx, rosaNet.Spec.StackName) + if err != nil { + return err + } + + rosaNet.Status.Resources = make([]expinfrav1.CFResource, len(*resources)) + for i, resource := range *resources { + rosaNet.Status.Resources[i] = expinfrav1.CFResource{ + LogicalID: aws.ToString(resource.LogicalResourceId), + PhysicalID: aws.ToString(resource.PhysicalResourceId), + ResourceType: aws.ToString(resource.ResourceType), + Status: string(resource.ResourceStatus), + Reason: aws.ToString(resource.ResourceStatusReason), + } + } + + return nil +} + +func (r *ROSANetworkReconciler) parseSubnets(rosaNet *expinfrav1.ROSANetwork) error { + subnets := make(map[string]expinfrav1.ROSANetworkSubnet) + + for _, resource := range rosaNet.Status.Resources { + if resource.ResourceType != "AWS::EC2::Subnet" { + continue + } + + az, err := r.awsClient.GetSubnetAvailabilityZone(resource.PhysicalID) + if err != nil { + return fmt.Errorf("failed to get AZ for subnet %s: %w", resource.PhysicalID, err) + } + + subnet := subnets[az] + subnet.AvailabilityZone = az + + if strings.HasPrefix(resource.LogicalID, "SubnetPrivate") { + subnet.PrivateSubnet = resource.PhysicalID + } else { + subnet.PublicSubnet = resource.PhysicalID + } + + subnets[az] = subnet + } + + rosaNet.Status.Subnets = slices.Collect(maps.Values(subnets)) + + return nil +} + +// SetupWithManager is used to setup the controller. +func (r *ROSANetworkReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, options controller.Options) error { + return ctrl.NewControllerManagedBy(mgr). + WithOptions(options). + For(&expinfrav1.ROSANetwork{}). + Complete(r) +} diff --git a/exp/controllers/rosanetwork_controller_test.go b/exp/controllers/rosanetwork_controller_test.go new file mode 100644 index 0000000000..74c775d5e4 --- /dev/null +++ b/exp/controllers/rosanetwork_controller_test.go @@ -0,0 +1,692 @@ +/* +Copyright The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "context" + "fmt" + "testing" + "time" + + awsSdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/cloudformation" + cloudformationtypes "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" + "github.com/aws/aws-sdk-go-v2/service/ec2" + ec2Types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + stsv2 "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/aws/smithy-go" + . "github.com/onsi/gomega" + rosaAWSClient "github.com/openshift/rosa/pkg/aws" + rosaMocks "github.com/openshift/rosa/pkg/aws/mocks" + "github.com/sirupsen/logrus" + gomock "go.uber.org/mock/gomock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + infrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + expinfrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/exp/api/v1beta2" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/conditions" +) + +func TestROSANetworkReconciler_Reconcile(t *testing.T) { + g := NewWithT(t) + ns, err := testEnv.CreateNamespace(ctx, "test-namespace") + g.Expect(err).ToNot(HaveOccurred()) + + mockCtrl := gomock.NewController(t) + ctx := context.TODO() + + identity := &infrav1.AWSClusterControllerIdentity{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: infrav1.AWSClusterControllerIdentitySpec{ + AWSClusterIdentitySpec: infrav1.AWSClusterIdentitySpec{ + AllowedNamespaces: &infrav1.AllowedNamespaces{}, + }, + }, + } + + name := "test-rosa-network" + rosaNetwork := &expinfrav1.ROSANetwork{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns.Name}, + Spec: expinfrav1.ROSANetworkSpec{ + StackName: name, + CIDRBlock: "10.0.0.0/8", + AvailabilityZoneCount: 1, + Region: "test-region", + IdentityRef: &infrav1.AWSIdentityReference{ + Name: identity.Name, + Kind: infrav1.ControllerIdentityKind, + }, + }, + } + + createObject(g, identity, ns.Name) + createObject(g, rosaNetwork, ns.Name) + + nameDeleted := "test-rosa-network-deleted" + rosaNetworkDeleted := &expinfrav1.ROSANetwork{ + ObjectMeta: metav1.ObjectMeta{ + Name: nameDeleted, + Namespace: ns.Name}, + Spec: expinfrav1.ROSANetworkSpec{ + StackName: nameDeleted, + CIDRBlock: "10.0.0.0/8", + AvailabilityZoneCount: 1, + Region: "test-region", + IdentityRef: &infrav1.AWSIdentityReference{ + Name: identity.Name, + Kind: infrav1.ControllerIdentityKind, + }, + }, + } + controllerutil.AddFinalizer(rosaNetworkDeleted, expinfrav1.ROSANetworkFinalizer) + createObject(g, rosaNetworkDeleted, ns.Name) + err = deleteROSANetwork(ctx, rosaNetworkDeleted) + g.Expect(err).NotTo(HaveOccurred()) + + t.Run("Empty result when ROSANetwork object not found", func(t *testing.T) { + _, _, _, reconciler := createMockClients(mockCtrl) + + req := ctrl.Request{} + req.NamespacedName = types.NamespacedName{Name: "non-existent-object", Namespace: "non-existent-namespace"} + reqReconcile, errReconcile := reconciler.Reconcile(ctx, req) + + g.Expect(reqReconcile.Requeue).To(BeFalse()) + g.Expect(reqReconcile.RequeueAfter).To(Equal(time.Duration(0))) + g.Expect(errReconcile).ToNot(HaveOccurred()) + }) + + t.Run("Error result when CF stack GET returns error", func(t *testing.T) { + _, mockCFClient, mockSTSClient, reconciler := createMockClients(mockCtrl) + + mockSTSIdentity(mockSTSClient) + mockDescribeStacksCall(mockCFClient, &cloudformation.DescribeStacksOutput{}, fmt.Errorf("test-error"), 1) + + req := ctrl.Request{} + req.NamespacedName = types.NamespacedName{Name: rosaNetwork.Name, Namespace: rosaNetwork.Namespace} + reqReconcile, errReconcile := reconciler.Reconcile(ctx, req) + + g.Expect(reqReconcile.Requeue).To(BeFalse()) + g.Expect(reqReconcile.RequeueAfter).To(Equal(time.Duration(0))) + g.Expect(errReconcile).To(MatchError(ContainSubstring("error fetching CF stack details:"))) + }) + + t.Run("Initial CF stack creation fails", func(t *testing.T) { + _, mockCFClient, mockSTSClient, reconciler := createMockClients(mockCtrl) + + mockSTSIdentity(mockSTSClient) + + describeStacksOutput := &cloudformation.DescribeStacksOutput{} + validationErr := &smithy.GenericAPIError{ + Code: "ValidationError", + Message: "ValidationError", + Fault: smithy.FaultServer, + } + + mockDescribeStacksCall(mockCFClient, describeStacksOutput, validationErr, 1) + mockCreateStackCall(mockCFClient, &cloudformation.CreateStackOutput{}, fmt.Errorf("test-error"), 1) + + req := ctrl.Request{} + req.NamespacedName = types.NamespacedName{Name: rosaNetwork.Name, Namespace: rosaNetwork.Namespace} + reqReconcile, errReconcile := reconciler.Reconcile(ctx, req) + + g.Expect(reqReconcile.Requeue).To(BeFalse()) + g.Expect(reqReconcile.RequeueAfter).To(Equal(time.Duration(0))) + g.Expect(errReconcile).To(MatchError(ContainSubstring("failed to start CF stack creation:"))) + + cnd, err := getROSANetworkReadyCondition(reconciler, rosaNetwork) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cnd).ToNot(BeNil()) + g.Expect(cnd.Reason).To(Equal(expinfrav1.ROSANetworkFailedReason)) + g.Expect(cnd.Severity).To(Equal(clusterv1.ConditionSeverityError)) + g.Expect(cnd.Message).To(Equal("test-error")) + }) + + t.Run("Initial CF stack creation succeeds", func(t *testing.T) { + _, mockCFClient, mockSTSClient, reconciler := createMockClients(mockCtrl) + + mockSTSIdentity(mockSTSClient) + + describeStacksOutput := &cloudformation.DescribeStacksOutput{} + validationErr := &smithy.GenericAPIError{ + Code: "ValidationError", + Message: "ValidationError", + Fault: smithy.FaultServer, + } + + mockDescribeStacksCall(mockCFClient, describeStacksOutput, validationErr, 1) + mockCreateStackCall(mockCFClient, &cloudformation.CreateStackOutput{}, nil, 1) + + req := ctrl.Request{} + req.NamespacedName = types.NamespacedName{Name: rosaNetwork.Name, Namespace: rosaNetwork.Namespace} + reqReconcile, errReconcile := reconciler.Reconcile(ctx, req) + + g.Expect(reqReconcile.Requeue).To(BeFalse()) + g.Expect(reqReconcile.RequeueAfter).To(Equal(time.Duration(0))) + g.Expect(errReconcile).ToNot(HaveOccurred()) + + cnd, err := getROSANetworkReadyCondition(reconciler, rosaNetwork) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cnd).ToNot(BeNil()) + g.Expect(cnd.Reason).To(Equal(expinfrav1.ROSANetworkCreatingReason)) + g.Expect(cnd.Severity).To(Equal(clusterv1.ConditionSeverityInfo)) + }) + + t.Run("CF stack creation is in progress", func(t *testing.T) { + _, mockCFClient, mockSTSClient, reconciler := createMockClients(mockCtrl) + + mockSTSIdentity(mockSTSClient) + + describeStacksOutput := &cloudformation.DescribeStacksOutput{ + Stacks: []cloudformationtypes.Stack{ + { + StackName: &name, + StackStatus: cloudformationtypes.StackStatusCreateInProgress, + }, + }, + } + mockDescribeStacksCall(mockCFClient, describeStacksOutput, nil, 1) + + mockDescribeStackResourcesCall(mockCFClient, &cloudformation.DescribeStackResourcesOutput{}, nil, 1) + + req := ctrl.Request{} + req.NamespacedName = types.NamespacedName{Name: rosaNetwork.Name, Namespace: rosaNetwork.Namespace} + reqReconcile, errReconcile := reconciler.Reconcile(ctx, req) + + g.Expect(reqReconcile.Requeue).To(BeFalse()) + g.Expect(reqReconcile.RequeueAfter).To(Equal(time.Second * 60)) + g.Expect(errReconcile).ToNot(HaveOccurred()) + + cnd, err := getROSANetworkReadyCondition(reconciler, rosaNetwork) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cnd).ToNot(BeNil()) + g.Expect(cnd.Reason).To(Equal(expinfrav1.ROSANetworkCreatingReason)) + g.Expect(cnd.Severity).To(Equal(clusterv1.ConditionSeverityInfo)) + }) + + t.Run("CF stack creation completed", func(t *testing.T) { + _, mockCFClient, mockSTSClient, reconciler := createMockClients(mockCtrl) + + mockSTSIdentity(mockSTSClient) + + describeStacksOutput := &cloudformation.DescribeStacksOutput{ + Stacks: []cloudformationtypes.Stack{ + { + StackName: &name, + StackStatus: cloudformationtypes.StackStatusCreateComplete, + }, + }, + } + mockDescribeStacksCall(mockCFClient, describeStacksOutput, nil, 1) + + mockDescribeStackResourcesCall(mockCFClient, &cloudformation.DescribeStackResourcesOutput{}, nil, 1) + + req := ctrl.Request{} + req.NamespacedName = types.NamespacedName{Name: rosaNetwork.Name, Namespace: rosaNetwork.Namespace} + reqReconcile, errReconcile := reconciler.Reconcile(ctx, req) + + g.Expect(reqReconcile.Requeue).To(BeFalse()) + g.Expect(reqReconcile.RequeueAfter).To(Equal(time.Duration(0))) + g.Expect(errReconcile).ToNot(HaveOccurred()) + + cnd, err := getROSANetworkReadyCondition(reconciler, rosaNetwork) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cnd).ToNot(BeNil()) + g.Expect(cnd.Reason).To(Equal(expinfrav1.ROSANetworkCreatedReason)) + g.Expect(cnd.Severity).To(Equal(clusterv1.ConditionSeverityInfo)) + }) + + t.Run("CF stack creation failed", func(t *testing.T) { + _, mockCFClient, mockSTSClient, reconciler := createMockClients(mockCtrl) + + mockSTSIdentity(mockSTSClient) + + describeStacksOutput := &cloudformation.DescribeStacksOutput{ + Stacks: []cloudformationtypes.Stack{ + { + StackName: &name, + StackStatus: cloudformationtypes.StackStatusCreateFailed, + }, + }, + } + mockDescribeStacksCall(mockCFClient, describeStacksOutput, nil, 1) + + mockDescribeStackResourcesCall(mockCFClient, &cloudformation.DescribeStackResourcesOutput{}, nil, 1) + + req := ctrl.Request{} + req.NamespacedName = types.NamespacedName{Name: rosaNetwork.Name, Namespace: rosaNetwork.Namespace} + reqReconcile, errReconcile := reconciler.Reconcile(ctx, req) + + g.Expect(reqReconcile.Requeue).To(BeFalse()) + g.Expect(reqReconcile.RequeueAfter).To(Equal(time.Duration(0))) + g.Expect(errReconcile).To(MatchError(ContainSubstring("creation failed"))) + + cnd, err := getROSANetworkReadyCondition(reconciler, rosaNetwork) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cnd).ToNot(BeNil()) + g.Expect(cnd.Reason).To(Equal(expinfrav1.ROSANetworkFailedReason)) + g.Expect(cnd.Severity).To(Equal(clusterv1.ConditionSeverityError)) + }) + + t.Run("CF stack deletion start failed", func(t *testing.T) { + _, mockCFClient, mockSTSClient, reconciler := createMockClients(mockCtrl) + + mockSTSIdentity(mockSTSClient) + + describeStacksOutput := &cloudformation.DescribeStacksOutput{ + Stacks: []cloudformationtypes.Stack{ + { + StackName: &nameDeleted, + StackStatus: cloudformationtypes.StackStatusCreateComplete, + }, + }, + } + mockDescribeStacksCall(mockCFClient, describeStacksOutput, nil, 1) + + mockDescribeStackResourcesCall(mockCFClient, &cloudformation.DescribeStackResourcesOutput{}, nil, 1) + + mockDeleteStackCall(mockCFClient, &cloudformation.DeleteStackOutput{}, fmt.Errorf("test-error"), 1) + + req := ctrl.Request{} + req.NamespacedName = types.NamespacedName{Name: nameDeleted, Namespace: rosaNetworkDeleted.Namespace} + reqReconcile, errReconcile := reconciler.Reconcile(ctx, req) + + g.Expect(reqReconcile.Requeue).To(BeFalse()) + g.Expect(reqReconcile.RequeueAfter).To(Equal(time.Duration(0))) + g.Expect(errReconcile).To(MatchError(ContainSubstring("failed to start CF stack deletion:"))) + + cnd, err := getROSANetworkReadyCondition(reconciler, rosaNetworkDeleted) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cnd).ToNot(BeNil()) + g.Expect(cnd.Reason).To(Equal(expinfrav1.ROSANetworkDeletionFailedReason)) + g.Expect(cnd.Severity).To(Equal(clusterv1.ConditionSeverityError)) + }) + + t.Run("CF stack deletion start succeeded", func(t *testing.T) { + _, mockCFClient, mockSTSClient, reconciler := createMockClients(mockCtrl) + + mockSTSIdentity(mockSTSClient) + + describeStacksOutput := &cloudformation.DescribeStacksOutput{ + Stacks: []cloudformationtypes.Stack{ + { + StackName: &nameDeleted, + StackStatus: cloudformationtypes.StackStatusCreateComplete, + }, + }, + } + mockDescribeStacksCall(mockCFClient, describeStacksOutput, nil, 1) + + mockDescribeStackResourcesCall(mockCFClient, &cloudformation.DescribeStackResourcesOutput{}, nil, 1) + + mockDeleteStackCall(mockCFClient, &cloudformation.DeleteStackOutput{}, nil, 1) + + req := ctrl.Request{} + req.NamespacedName = types.NamespacedName{Name: nameDeleted, Namespace: rosaNetworkDeleted.Namespace} + reqReconcile, errReconcile := reconciler.Reconcile(ctx, req) + + g.Expect(reqReconcile.Requeue).To(BeFalse()) + g.Expect(reqReconcile.RequeueAfter).To(Equal(60 * time.Second)) + g.Expect(errReconcile).NotTo(HaveOccurred()) + + cnd, err := getROSANetworkReadyCondition(reconciler, rosaNetworkDeleted) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cnd).ToNot(BeNil()) + g.Expect(cnd.Reason).To(Equal(expinfrav1.ROSANetworkDeletingReason)) + g.Expect(cnd.Severity).To(Equal(clusterv1.ConditionSeverityInfo)) + }) + + t.Run("CF stack deletion in progress", func(t *testing.T) { + _, mockCFClient, mockSTSClient, reconciler := createMockClients(mockCtrl) + + mockSTSIdentity(mockSTSClient) + + describeStacksOutput := &cloudformation.DescribeStacksOutput{ + Stacks: []cloudformationtypes.Stack{ + { + StackName: &nameDeleted, + StackStatus: cloudformationtypes.StackStatusDeleteInProgress, + }, + }, + } + mockDescribeStacksCall(mockCFClient, describeStacksOutput, nil, 1) + + mockDescribeStackResourcesCall(mockCFClient, &cloudformation.DescribeStackResourcesOutput{}, nil, 1) + + req := ctrl.Request{} + req.NamespacedName = types.NamespacedName{Name: nameDeleted, Namespace: rosaNetworkDeleted.Namespace} + reqReconcile, errReconcile := reconciler.Reconcile(ctx, req) + + g.Expect(reqReconcile.Requeue).To(BeFalse()) + g.Expect(reqReconcile.RequeueAfter).To(Equal(60 * time.Second)) + g.Expect(errReconcile).NotTo(HaveOccurred()) + }) + + t.Run("CF stack deletion failed", func(t *testing.T) { + _, mockCFClient, mockSTSClient, reconciler := createMockClients(mockCtrl) + + mockSTSIdentity(mockSTSClient) + + describeStacksOutput := &cloudformation.DescribeStacksOutput{ + Stacks: []cloudformationtypes.Stack{ + { + StackName: &nameDeleted, + StackStatus: cloudformationtypes.StackStatusDeleteFailed, + }, + }, + } + + mockDescribeStacksCall(mockCFClient, describeStacksOutput, nil, 1) + + describeStackResourcesOutput := &cloudformation.DescribeStackResourcesOutput{ + StackResources: []cloudformationtypes.StackResource{}, + } + + mockDescribeStackResourcesCall(mockCFClient, describeStackResourcesOutput, nil, 1) + + req := ctrl.Request{} + req.NamespacedName = types.NamespacedName{Name: nameDeleted, Namespace: rosaNetworkDeleted.Namespace} + reqReconcile, errReconcile := reconciler.Reconcile(ctx, req) + + g.Expect(reqReconcile.Requeue).To(BeFalse()) + g.Expect(reqReconcile.RequeueAfter).To(Equal(time.Duration(0))) + g.Expect(errReconcile).To(MatchError(ContainSubstring("CF stack deletion failed"))) + + cnd, err := getROSANetworkReadyCondition(reconciler, rosaNetworkDeleted) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cnd).ToNot(BeNil()) + g.Expect(cnd.Reason).To(Equal(expinfrav1.ROSANetworkDeletionFailedReason)) + g.Expect(cnd.Severity).To(Equal(clusterv1.ConditionSeverityError)) + }) + + cleanupObject(g, rosaNetwork) + cleanupObject(g, rosaNetworkDeleted) + cleanupObject(g, identity) +} + +func TestROSANetworkReconciler_updateROSANetworkResources(t *testing.T) { + g := NewWithT(t) + mockCtrl := gomock.NewController(t) + ctx := context.TODO() + + rosaNetwork := &expinfrav1.ROSANetwork{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rosa-network", + Namespace: "test-namespace", + }, + Spec: expinfrav1.ROSANetworkSpec{}, + Status: expinfrav1.ROSANetworkStatus{}, + } + + t.Run("Handle cloudformation client error", func(t *testing.T) { + _, mockCFClient, _, reconciler := createMockClients(mockCtrl) + + mockDescribeStackResourcesCall(mockCFClient, &cloudformation.DescribeStackResourcesOutput{}, fmt.Errorf("test-error"), 1) + + err := reconciler.updateROSANetworkResources(ctx, rosaNetwork) + g.Expect(err).To(HaveOccurred()) + g.Expect(len(rosaNetwork.Status.Resources)).To(Equal(0)) + }) + + t.Run("Update ROSANetwork.Status.Resources", func(t *testing.T) { + _, mockCFClient, _, reconciler := createMockClients(mockCtrl) + + logicalResourceID := "logical-resource-id" + resourceStatus := cloudformationtypes.ResourceStatusCreateComplete + resourceType := "resource-type" + resourceStatusReason := "resource-status-reason" + physicalResourceID := "physical-resource-id" + + describeStackResourcesOutput := &cloudformation.DescribeStackResourcesOutput{ + StackResources: []cloudformationtypes.StackResource{ + { + LogicalResourceId: &logicalResourceID, + ResourceStatus: resourceStatus, + ResourceType: &resourceType, + ResourceStatusReason: &resourceStatusReason, + PhysicalResourceId: &physicalResourceID, + }, + }, + } + + mockDescribeStackResourcesCall(mockCFClient, describeStackResourcesOutput, nil, 1) + + err := reconciler.updateROSANetworkResources(ctx, rosaNetwork) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(rosaNetwork.Status.Resources[0].LogicalID).To(Equal(logicalResourceID)) + g.Expect(rosaNetwork.Status.Resources[0].Status).To(Equal(string(resourceStatus))) + g.Expect(rosaNetwork.Status.Resources[0].ResourceType).To(Equal(resourceType)) + g.Expect(rosaNetwork.Status.Resources[0].Reason).To(Equal(resourceStatusReason)) + g.Expect(rosaNetwork.Status.Resources[0].PhysicalID).To(Equal(physicalResourceID)) + }) +} + +func TestROSANetworkReconciler_parseSubnets(t *testing.T) { + g := NewWithT(t) + mockCtrl := gomock.NewController(t) + + subnet1Id := "subnet1-physical-id" + subnet2Id := "subnet2-physical-id" + + rosaNetwork := &expinfrav1.ROSANetwork{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rosa-network", + Namespace: "test-namespace", + }, + Spec: expinfrav1.ROSANetworkSpec{}, + Status: expinfrav1.ROSANetworkStatus{ + Resources: []expinfrav1.CFResource{ + { + ResourceType: "AWS::EC2::Subnet", + LogicalID: "SubnetPrivate", + PhysicalID: subnet1Id, + Status: "subnet1-status", + Reason: "subnet1-reason", + }, + { + ResourceType: "AWS::EC2::Subnet", + LogicalID: "SubnetPublic", + PhysicalID: subnet2Id, + Status: "subnet2-status", + Reason: "subnet2-reason", + }, + { + ResourceType: "bogus-type", + LogicalID: "bogus-logical-id", + PhysicalID: "bugus-physical-id", + Status: "bogus-status", + Reason: "bogus-reason", + }, + }, + }, + } + + t.Run("Handle EC2 client error", func(t *testing.T) { + mockEC2Client, _, _, reconciler := createMockClients(mockCtrl) + + mockDescribeSubnetsCall(mockEC2Client, &ec2.DescribeSubnetsOutput{}, nil, 1) + + err := reconciler.parseSubnets(rosaNetwork) + g.Expect(err).To(HaveOccurred()) + g.Expect(len(rosaNetwork.Status.Subnets)).To(Equal(0)) + }) + + t.Run("Update ROSANetwork.Status.Subnets", func(t *testing.T) { + mockEC2Client, _, _, reconciler := createMockClients(mockCtrl) + + az := "az01" + + describeSubnetsOutput := &ec2.DescribeSubnetsOutput{ + Subnets: []ec2Types.Subnet{ + { + AvailabilityZone: &az, + }, + }, + } + + mockDescribeSubnetsCall(mockEC2Client, describeSubnetsOutput, nil, 2) + + err := reconciler.parseSubnets(rosaNetwork) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(rosaNetwork.Status.Subnets[0].AvailabilityZone).To(Equal(az)) + g.Expect(rosaNetwork.Status.Subnets[0].PrivateSubnet).To(Equal(subnet1Id)) + g.Expect(rosaNetwork.Status.Subnets[0].PublicSubnet).To(Equal(subnet2Id)) + }) +} + +func createMockClients(mockCtrl *gomock.Controller) (*rosaMocks.MockEc2ApiClient, *rosaMocks.MockCloudFormationApiClient, *rosaMocks.MockStsApiClient, *ROSANetworkReconciler) { + mockEC2Client := rosaMocks.NewMockEc2ApiClient(mockCtrl) + mockCFClient := rosaMocks.NewMockCloudFormationApiClient(mockCtrl) + mockSTSClient := rosaMocks.NewMockStsApiClient(mockCtrl) + awsClient := rosaAWSClient.New( + awsSdk.Config{}, + rosaAWSClient.NewLoggerWrapper(logrus.New(), nil), + rosaMocks.NewMockIamApiClient(mockCtrl), + mockEC2Client, + rosaMocks.NewMockOrganizationsApiClient(mockCtrl), + rosaMocks.NewMockS3ApiClient(mockCtrl), + rosaMocks.NewMockSecretsManagerApiClient(mockCtrl), + mockSTSClient, + mockCFClient, + rosaMocks.NewMockServiceQuotasApiClient(mockCtrl), + rosaMocks.NewMockServiceQuotasApiClient(mockCtrl), + &rosaAWSClient.AccessKey{}, + false, + ) + + reconciler := &ROSANetworkReconciler{ + Client: testEnv.Client, + awsClient: awsClient, + } + + return mockEC2Client, mockCFClient, mockSTSClient, reconciler +} + +func mockSTSIdentity(mockSTSClient *rosaMocks.MockStsApiClient) { + getCallerIdentityResult := &stsv2.GetCallerIdentityOutput{ + Account: awsSdk.String("foo"), + Arn: awsSdk.String("arn:aws:iam::123456789012:rosa/foo"), + } + mockSTSClient. + EXPECT(). + GetCallerIdentity(gomock.Any(), gomock.Any()). + Return(getCallerIdentityResult, nil). + AnyTimes() +} + +func mockDescribeStacksCall(mockCFClient *rosaMocks.MockCloudFormationApiClient, output *cloudformation.DescribeStacksOutput, err error, times int) { + mockCFClient. + EXPECT(). + DescribeStacks(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, + _ *cloudformation.DescribeStacksInput, + _ ...func(*cloudformation.Options)) (*cloudformation.DescribeStacksOutput, error) { + return output, err + }). + Times(times) +} + +func mockCreateStackCall(mockCFClient *rosaMocks.MockCloudFormationApiClient, output *cloudformation.CreateStackOutput, err error, times int) { + mockCFClient. + EXPECT(). + CreateStack(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, + _ *cloudformation.CreateStackInput, + _ ...func(*cloudformation.Options)) (*cloudformation.CreateStackOutput, error) { + return output, err + }). + Times(times) +} + +func mockDescribeStackResourcesCall(mockCFClient *rosaMocks.MockCloudFormationApiClient, output *cloudformation.DescribeStackResourcesOutput, err error, times int) { + mockCFClient. + EXPECT(). + DescribeStackResources(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, + _ *cloudformation.DescribeStackResourcesInput, + _ ...func(*cloudformation.Options)) (*cloudformation.DescribeStackResourcesOutput, error) { + return output, err + }). + Times(times) +} + +func mockDeleteStackCall(mockCFClient *rosaMocks.MockCloudFormationApiClient, output *cloudformation.DeleteStackOutput, err error, times int) { + mockCFClient. + EXPECT(). + DeleteStack(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, + _ *cloudformation.DeleteStackInput, + _ ...func(*cloudformation.Options)) (*cloudformation.DeleteStackOutput, error) { + return output, err + }). + Times(times) +} + +func mockDescribeSubnetsCall(mockEc2Client *rosaMocks.MockEc2ApiClient, output *ec2.DescribeSubnetsOutput, err error, times int) { + mockEc2Client. + EXPECT(). + DescribeSubnets(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, + _ *ec2.DescribeSubnetsInput, + _ ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error) { + return output, err + }). + Times(times) +} + +func deleteROSANetwork(ctx context.Context, rosaNetwork *expinfrav1.ROSANetwork) error { + if err := testEnv.Client.Get(ctx, client.ObjectKeyFromObject(rosaNetwork), rosaNetwork); err != nil { + return err + } + + if !rosaNetwork.ObjectMeta.DeletionTimestamp.IsZero() { + return nil + } + + if err := testEnv.Client.Delete(ctx, rosaNetwork); err != nil { + return err + } + + for { + if err := testEnv.Client.Get(ctx, client.ObjectKeyFromObject(rosaNetwork), rosaNetwork); err != nil { + return err + } + + if !rosaNetwork.ObjectMeta.DeletionTimestamp.IsZero() { + break + } + + time.Sleep(50 * time.Millisecond) + } + + return nil +} + +func getROSANetworkReadyCondition(reconciler *ROSANetworkReconciler, rosaNet *expinfrav1.ROSANetwork) (*clusterv1.Condition, error) { + updatedROSANetwork := &expinfrav1.ROSANetwork{} + + if err := reconciler.Client.Get(ctx, client.ObjectKeyFromObject(rosaNet), updatedROSANetwork); err != nil { + return nil, err + } + + return conditions.Get(updatedROSANetwork, expinfrav1.ROSANetworkReadyCondition), nil +} diff --git a/go.mod b/go.mod index 1ffc472259..651820d3f2 100644 --- a/go.mod +++ b/go.mod @@ -49,6 +49,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/zgalor/weberr v0.8.2 + go.uber.org/mock v0.5.2 golang.org/x/crypto v0.36.0 golang.org/x/net v0.38.0 golang.org/x/text v0.23.0 @@ -72,6 +73,13 @@ require ( require github.com/aws/aws-sdk-go v1.55.7 // indirect +require ( + github.com/AlecAivazis/survey/v2 v2.2.15 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect +) + require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect cel.dev/expr v0.18.0 // indirect @@ -219,7 +227,6 @@ require ( go.opentelemetry.io/otel/sdk v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect - go.uber.org/mock v0.5.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect diff --git a/go.sum b/go.sum index 61860ee4e4..244185fbfc 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXG github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw= +github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= @@ -314,6 +316,8 @@ github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8 github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ= +github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= @@ -361,6 +365,8 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.4 h1:5Myjjh3JY/NaAi4IsUbHADytDyl1VE1Y9PXDlL+P/VQ= +github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= @@ -378,6 +384,7 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= @@ -497,6 +504,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -578,6 +586,7 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -645,6 +654,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/main.go b/main.go index 8aac35b373..63afc9d87a 100644 --- a/main.go +++ b/main.go @@ -270,6 +270,16 @@ func main() { os.Exit(1) } + setupLog.Debug("enabling ROSA network controller") + if err = (&expcontrollers.ROSANetworkReconciler{ + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controllers").WithName("ROSANetwork"), + Scheme: mgr.GetScheme(), + }).SetupWithManager(ctx, mgr, controller.Options{MaxConcurrentReconciles: awsClusterConcurrency, RecoverPanic: ptr.To[bool](true)}); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ROSANetwork") + os.Exit(1) + } + if err := (&rosacontrolplanev1.ROSAControlPlane{}).SetupWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "ROSAControlPlane") os.Exit(1) diff --git a/pkg/cloud/interfaces.go b/pkg/cloud/interfaces.go index 82c671e2c1..82ef9ad363 100644 --- a/pkg/cloud/interfaces.go +++ b/pkg/cloud/interfaces.go @@ -104,4 +104,6 @@ type SessionMetadata interface { InfraCluster() ClusterObject // IdentityRef returns the AWS infrastructure cluster identityRef. IdentityRef() *infrav1.AWSIdentityReference + // ControllerName returns the controller name + ControllerName() string } diff --git a/pkg/cloud/scope/rosanetwork.go b/pkg/cloud/scope/rosanetwork.go new file mode 100644 index 0000000000..580b0c47e7 --- /dev/null +++ b/pkg/cloud/scope/rosanetwork.go @@ -0,0 +1,136 @@ +/* + Copyright The Kubernetes Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package scope + +import ( + "context" + + awsv2 "github.com/aws/aws-sdk-go-v2/aws" + "github.com/pkg/errors" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + + infrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + expinfrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/exp/api/v1beta2" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/throttle" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/logger" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/patch" +) + +// ROSANetworkScopeParams defines the input parameters used to create a new ROSANetworkScope. +type ROSANetworkScopeParams struct { + Client client.Client + ControllerName string + Logger *logger.Logger + ROSANetwork *expinfrav1.ROSANetwork +} + +// ROSANetworkScope defines the basic context for an actuator to operate upon. +type ROSANetworkScope struct { + logger.Logger + Client client.Client + controllerName string + patchHelper *patch.Helper + ROSANetwork *expinfrav1.ROSANetwork + serviceLimiters throttle.ServiceLimiters + session awsv2.Config +} + +// NewROSANetworkScope creates a new NewROSANetworkScope from the supplied parameters. +func NewROSANetworkScope(params ROSANetworkScopeParams) (*ROSANetworkScope, error) { + if params.Logger == nil { + log := klog.Background() + params.Logger = logger.NewLogger(log) + } + + rosaNetworkScope := &ROSANetworkScope{ + Logger: *params.Logger, + Client: params.Client, + controllerName: params.ControllerName, + patchHelper: nil, + ROSANetwork: params.ROSANetwork, + } + + session, serviceLimiters, err := sessionForClusterWithRegion(params.Client, rosaNetworkScope, params.ROSANetwork.Spec.Region, params.Logger) + if err != nil { + return nil, errors.Errorf("failed to create aws V2 session: %v", err) + } + + patchHelper, err := patch.NewHelper(params.ROSANetwork, params.Client) + if err != nil { + return nil, errors.Wrap(err, "failed to init patch helper") + } + + rosaNetworkScope.patchHelper = patchHelper + rosaNetworkScope.session = *session + rosaNetworkScope.serviceLimiters = serviceLimiters + + return rosaNetworkScope, nil +} + +// Session returns the AWS SDK V2 Config. Used for creating clients. +func (s *ROSANetworkScope) Session() awsv2.Config { + return s.session +} + +// IdentityRef returns the AWSIdentityReference object. +func (s *ROSANetworkScope) IdentityRef() *infrav1.AWSIdentityReference { + return s.ROSANetwork.Spec.IdentityRef +} + +// ServiceLimiter returns the AWS SDK session (used for creating clients). +func (s *ROSANetworkScope) ServiceLimiter(service string) *throttle.ServiceLimiter { + if sl, ok := s.serviceLimiters[service]; ok { + return sl + } + return nil +} + +// ControllerName returns the name of the controller. +func (s *ROSANetworkScope) ControllerName() string { + return s.controllerName +} + +// InfraCluster returns the ROSANetwork object. +// The method is then used in session.go to set proper Conditions for the ROSANetwork object. +func (s *ROSANetworkScope) InfraCluster() cloud.ClusterObject { + return s.ROSANetwork +} + +// InfraClusterName returns the name of the ROSANetwork object. +// The method is then used in session.go to set the key to the AWS session cache. +func (s *ROSANetworkScope) InfraClusterName() string { + return s.ROSANetwork.Name +} + +// Namespace returns the namespace of the ROSANetwork object. +// The method is then used in session.go to set the key to the AWS session cache. +func (s *ROSANetworkScope) Namespace() string { + return s.ROSANetwork.Namespace +} + +// PatchObject persists the rosanetwork configuration and status. +func (s *ROSANetworkScope) PatchObject() error { + return s.patchHelper.Patch( + context.TODO(), + s.ROSANetwork, + patch.WithOwnedConditions{Conditions: []clusterv1.ConditionType{ + expinfrav1.ROSANetworkReadyCondition, + }}) +} diff --git a/pkg/cloud/scope/rosanetwork_test.go b/pkg/cloud/scope/rosanetwork_test.go new file mode 100644 index 0000000000..d75531d8cf --- /dev/null +++ b/pkg/cloud/scope/rosanetwork_test.go @@ -0,0 +1,122 @@ +/* + Copyright The Kubernetes Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package scope + +import ( + "testing" + + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + infrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2" + expinfrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/exp/api/v1beta2" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/logger" + "sigs.k8s.io/cluster-api-provider-aws/v2/util/system" +) + +func TestNewROSANetworkScope(t *testing.T) { + g := NewGomegaWithT(t) + + scheme := runtime.NewScheme() + corev1.AddToScheme(scheme) + infrav1.AddToScheme(scheme) + expinfrav1.AddToScheme(scheme) + + clusterControllerIdentity := &infrav1.AWSClusterControllerIdentity{ + TypeMeta: metav1.TypeMeta{ + Kind: string(infrav1.ControllerIdentityKind), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: infrav1.AWSClusterControllerIdentitySpec{ + AWSClusterIdentitySpec: infrav1.AWSClusterIdentitySpec{ + AllowedNamespaces: &infrav1.AllowedNamespaces{}, + }, + }, + } + + staticSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "static-secret", + Namespace: system.GetManagerNamespace(), + }, + Data: map[string][]byte{ + "AccessKeyID": []byte("access-key-id"), + "SecretAccessKey": []byte("secret-access-key"), + }, + } + + clusterStaticIdentity := &infrav1.AWSClusterStaticIdentity{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-static-identity", + }, + Spec: infrav1.AWSClusterStaticIdentitySpec{ + SecretRef: "static-secret", + AWSClusterIdentitySpec: infrav1.AWSClusterIdentitySpec{ + AllowedNamespaces: &infrav1.AllowedNamespaces{}, + }, + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(clusterControllerIdentity, staticSecret, clusterStaticIdentity).Build() + + rosaNetwork := expinfrav1.ROSANetwork{ + TypeMeta: metav1.TypeMeta{ + Kind: "ROSANetwork", + APIVersion: "v1beta2", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rosa-net", + Namespace: "test-namespace", + }, + Spec: expinfrav1.ROSANetworkSpec{ + IdentityRef: &infrav1.AWSIdentityReference{ + Name: "default", + Kind: "AWSClusterControllerIdentity", + }, + }, + Status: expinfrav1.ROSANetworkStatus{}, + } + + rosaNetScopeParams := ROSANetworkScopeParams{ + Client: fakeClient, + ControllerName: "test-rosanet-controller", + Logger: logger.NewLogger(klog.Background()), + ROSANetwork: &rosaNetwork, + } + + rosaNetScope, err := NewROSANetworkScope(rosaNetScopeParams) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(rosaNetScope.ControllerName()).To(Equal("test-rosanet-controller")) + g.Expect(rosaNetScope.InfraCluster()).To(Equal(&rosaNetwork)) + g.Expect(rosaNetScope.InfraClusterName()).To(Equal("test-rosa-net")) + g.Expect(rosaNetScope.Namespace()).To(Equal("test-namespace")) + g.Expect(rosaNetScope.IdentityRef()).To(Equal(rosaNetwork.Spec.IdentityRef)) + g.Expect(rosaNetScope.Session()).ToNot(BeNil()) + + // AWSClusterStaticIdentity + rosaNetwork.Spec.IdentityRef.Name = "cluster-static-identity" + rosaNetwork.Spec.IdentityRef.Kind = "AWSClusterStaticIdentity" + rosaNetScope, err = NewROSANetworkScope(rosaNetScopeParams) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(rosaNetScope.Session()).ToNot(BeNil()) +} diff --git a/pkg/cloud/scope/session.go b/pkg/cloud/scope/session.go index ae6ff23244..3a18e65faf 100644 --- a/pkg/cloud/scope/session.go +++ b/pkg/cloud/scope/session.go @@ -156,7 +156,7 @@ func sessionForClusterWithRegion(k8sClient client.Client, clusterScoper cloud.Se } func getSessionName(region string, clusterScoper cloud.SessionMetadata) string { - return fmt.Sprintf("%s-%s-%s", region, clusterScoper.InfraClusterName(), clusterScoper.Namespace()) + return fmt.Sprintf("%s-%s-%s-%s", region, clusterScoper.ControllerName(), clusterScoper.InfraClusterName(), clusterScoper.Namespace()) } func newServiceLimiters() throttle.ServiceLimiters { diff --git a/templates/rosa-network.yaml b/templates/rosa-network.yaml new file mode 100644 index 0000000000..7f34dd03bf --- /dev/null +++ b/templates/rosa-network.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 +kind: ROSANetwork +metadata: + name: "${ROSA_NETWORK_NAME}" +spec: + region: "${AWS_REGION}" + stackName: "${ROSA_NETWORK_NAME}" + availabilityZoneCount: ${AVAILABILITY_ZONE_COUNT} + cidrBlock: "${CIDR_BLOCK}" + identityRef: + kind: AWSClusterControllerIdentity + name: default