diff --git a/api/v1alpha1/connector_types.go b/api/v1alpha1/connector_types.go index 3869c9ad..d7b0c54c 100644 --- a/api/v1alpha1/connector_types.go +++ b/api/v1alpha1/connector_types.go @@ -3,6 +3,7 @@ package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -33,7 +34,9 @@ type ConnectorCapability struct { // ConnectorSpec defines the desired state of Connector. type ConnectorSpec struct { - ConnectorClassName string `json:"connectorClassName,omitempty"` + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + ConnectorClassName string `json:"connectorClassName"` // Capabilities desired to be supported by the connector. // @@ -136,8 +139,34 @@ type ConnectorStatus struct { // // +kubebuilder:validation:Optional ConnectionDetails *ConnectorConnectionDetails `json:"connectionDetails,omitempty"` + + // LeaseRef references the Lease used to report connector liveness. + // + // The connector controller creates the Lease when a Connector is created + // and records it here. Connector implementations (agents) are expected to + // periodically renew the Lease to indicate liveness. + // + // +kubebuilder:validation:Optional + LeaseRef *corev1.LocalObjectReference `json:"leaseRef,omitempty"` } +const ( + // ConnectorConditionAccepted indicates whether the ConnectorClass is resolved. + ConnectorConditionAccepted = "Accepted" + // ConnectorConditionReady indicates whether the Connector is ready to tunnel traffic. + ConnectorConditionReady = "Ready" + // ConnectorReasonAccepted indicates the Connector is accepted by the controller. + ConnectorReasonAccepted = "Accepted" + // ConnectorReasonReady indicates the Connector is ready to tunnel traffic. + ConnectorReasonReady = "ConnectorReady" + // ConnectorReasonNotReady indicates the Connector is not ready to tunnel traffic. + ConnectorReasonNotReady = "ConnectorNotReady" + // ConnectorReasonPending indicates the Connector has not been processed yet. + ConnectorReasonPending = "Pending" + // ConnectorReasonConnectorClassNotFound indicates the referenced class is missing. + ConnectorReasonConnectorClassNotFound = "ConnectorClassNotFound" +) + const ConnectorNameAnnotation = "networking.datum.org/connector-name" // +kubebuilder:object:root=true @@ -154,6 +183,8 @@ type Connector struct { Spec ConnectorSpec `json:"spec,omitempty"` // Status defines the observed state of a Connector + // + // +kubebuilder:default={conditions: {{type: "Accepted", status: "Unknown", reason:"Pending", message:"Waiting for controller", lastTransitionTime: "1970-01-01T00:00:00Z"}}} Status ConnectorStatus `json:"status,omitempty"` } diff --git a/api/v1alpha1/connectoradvertisement_types.go b/api/v1alpha1/connectoradvertisement_types.go index e5e8b00a..95aca442 100644 --- a/api/v1alpha1/connectoradvertisement_types.go +++ b/api/v1alpha1/connectoradvertisement_types.go @@ -84,7 +84,7 @@ type ConnectorAdvertisementSpec struct { // ConnectorRef references the Connector being advertised. // // +kubebuilder:validation:Required - ConnectorRef *LocalConnectorReference `json:"connectorRef"` + ConnectorRef LocalConnectorReference `json:"connectorRef"` // Layer 4 services being advertised. // @@ -97,10 +97,32 @@ type ConnectorAdvertisementSpec struct { // ConnectorAdvertisementStatus defines the observed state of ConnectorAdvertisement. type ConnectorAdvertisementStatus struct { + // Conditions describe the current conditions of the ConnectorAdvertisement. + // + // Known conditions: + // - Accepted: indicates whether the referenced Connector has been resolved. + // When Accepted is False, the reason will explain why the reference + // could not be resolved (for example, ConnectorNotFound). + // + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` } +const ( + // ConnectorAdvertisementConditionAccepted indicates the connector reference is resolved. + ConnectorAdvertisementConditionAccepted = "Accepted" + // ConnectorAdvertisementReasonAccepted indicates the advertisement is accepted. + ConnectorAdvertisementReasonAccepted = "Accepted" + // ConnectorAdvertisementReasonPending indicates the advertisement has not been processed yet. + ConnectorAdvertisementReasonPending = "Pending" + // ConnectorAdvertisementReasonConnectorNotFound indicates the referenced connector is missing. + ConnectorAdvertisementReasonConnectorNotFound = "ConnectorNotFound" +) + // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:selectablefield:JSONPath=".spec.connectorRef.name" // ConnectorAdvertisement is the Schema for the connectoradvertisements API. type ConnectorAdvertisement struct { @@ -113,6 +135,8 @@ type ConnectorAdvertisement struct { Spec ConnectorAdvertisementSpec `json:"spec,omitempty"` // Status defines the observed state of a ConnectorAdvertisement + // + // +kubebuilder:default={conditions: {{type: "Accepted", status: "Unknown", reason:"Pending", message:"Waiting for controller", lastTransitionTime: "1970-01-01T00:00:00Z"}}} Status ConnectorAdvertisementStatus `json:"status,omitempty"` } diff --git a/api/v1alpha1/connectorclass_types.go b/api/v1alpha1/connectorclass_types.go index 4fecea40..29b33cec 100644 --- a/api/v1alpha1/connectorclass_types.go +++ b/api/v1alpha1/connectorclass_types.go @@ -8,6 +8,13 @@ import ( // ConnectorClassSpec defines the desired state of ConnectorClass. type ConnectorClassSpec struct { + // ControllerName is the name of the controller responsible for this ConnectorClass. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:default=networking.datumapis.com/datum-connect + ControllerName string `json:"controllerName"` } // ConnectorClassStatus defines the observed state of ConnectorClass. diff --git a/api/v1alpha1/object_reference_types.go b/api/v1alpha1/object_reference_types.go index 71cb8dfa..ba22afa4 100644 --- a/api/v1alpha1/object_reference_types.go +++ b/api/v1alpha1/object_reference_types.go @@ -4,5 +4,7 @@ type LocalConnectorReference struct { // Name of the referenced Connector. // // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 Name string `json:"name"` } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8b033989..5415f1ff 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -7,6 +7,7 @@ package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -44,7 +45,7 @@ func (in *ConnectorAdvertisement) DeepCopyInto(out *ConnectorAdvertisement) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectorAdvertisement. @@ -142,11 +143,7 @@ func (in *ConnectorAdvertisementList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConnectorAdvertisementSpec) DeepCopyInto(out *ConnectorAdvertisementSpec) { *out = *in - if in.ConnectorRef != nil { - in, out := &in.ConnectorRef, &out.ConnectorRef - *out = new(LocalConnectorReference) - **out = **in - } + out.ConnectorRef = in.ConnectorRef if in.Layer4 != nil { in, out := &in.Layer4, &out.Layer4 *out = make([]ConnectorAdvertisementLayer4, len(*in)) @@ -169,6 +166,13 @@ func (in *ConnectorAdvertisementSpec) DeepCopy() *ConnectorAdvertisementSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConnectorAdvertisementStatus) DeepCopyInto(out *ConnectorAdvertisementStatus) { *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectorAdvertisementStatus. @@ -459,6 +463,11 @@ func (in *ConnectorStatus) DeepCopyInto(out *ConnectorStatus) { *out = new(ConnectorConnectionDetails) (*in).DeepCopyInto(*out) } + if in.LeaseRef != nil { + in, out := &in.LeaseRef, &out.LeaseRef + *out = new(corev1.LocalObjectReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectorStatus. diff --git a/cmd/main.go b/cmd/main.go index a2e9e57e..058ab9f3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -280,6 +280,17 @@ func main() { os.Exit(1) } + if err := (&controller.ConnectorReconciler{ + Config: serverConfig, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Connector") + os.Exit(1) + } + if err := (&controller.ConnectorAdvertisementReconciler{}).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ConnectorAdvertisement") + os.Exit(1) + } + if err := controller.AddIndexers(ctx, mgr); err != nil { setupLog.Error(err, "unable to add indexers") os.Exit(1) diff --git a/config/crd/bases/networking.datumapis.com_connectoradvertisements.yaml b/config/crd/bases/networking.datumapis.com_connectoradvertisements.yaml index 2bc646bc..37880343 100644 --- a/config/crd/bases/networking.datumapis.com_connectoradvertisements.yaml +++ b/config/crd/bases/networking.datumapis.com_connectoradvertisements.yaml @@ -45,6 +45,8 @@ spec: properties: name: description: Name of the referenced Connector. + maxLength: 253 + minLength: 1 type: string required: - name @@ -123,11 +125,87 @@ spec: - connectorRef type: object status: + default: + conditions: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: Waiting for controller + reason: Pending + status: Unknown + type: Accepted description: Status defines the observed state of a ConnectorAdvertisement + properties: + conditions: + description: |- + Conditions describe the current conditions of the ConnectorAdvertisement. + + Known conditions: + - Accepted: indicates whether the referenced Connector has been resolved. + When Accepted is False, the reason will explain why the reference + could not be resolved (for example, ConnectorNotFound). + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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 may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map type: object required: - spec type: object + selectableFields: + - jsonPath: .spec.connectorRef.name served: true storage: true subresources: diff --git a/config/crd/bases/networking.datumapis.com_connectorclasses.yaml b/config/crd/bases/networking.datumapis.com_connectorclasses.yaml index 5c851d36..c507a5bb 100644 --- a/config/crd/bases/networking.datumapis.com_connectorclasses.yaml +++ b/config/crd/bases/networking.datumapis.com_connectorclasses.yaml @@ -38,6 +38,16 @@ spec: type: object spec: description: Spec defines the desired state of a ConnectorClass + properties: + controllerName: + default: networking.datumapis.com/datum-connect + description: ControllerName is the name of the controller responsible + for this ConnectorClass. + maxLength: 253 + minLength: 1 + type: string + required: + - controllerName type: object status: description: Status defines the observed state of a ConnectorClass diff --git a/config/crd/bases/networking.datumapis.com_connectors.yaml b/config/crd/bases/networking.datumapis.com_connectors.yaml index fffa55db..bcc9c7b7 100644 --- a/config/crd/bases/networking.datumapis.com_connectors.yaml +++ b/config/crd/bases/networking.datumapis.com_connectors.yaml @@ -68,9 +68,19 @@ spec: - type x-kubernetes-list-type: map connectorClassName: + minLength: 1 type: string + required: + - connectorClassName type: object status: + default: + conditions: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: Waiting for controller + reason: Pending + status: Unknown + type: Accepted description: Status defines the observed state of a Connector properties: capabilities: @@ -265,6 +275,25 @@ spec: rule: '!(self.type != ''PublicKey'' && has(self.publicKey))' - message: publicKey field must be specified if the type is PublicKey rule: self.type == 'PublicKey' && has(self.publicKey) + leaseRef: + description: |- + LeaseRef references the Lease used to report connector liveness. + + The connector controller creates the Lease when a Connector is created + and records it here. Connector implementations (agents) are expected to + periodically renew the Lease to indicate liveness. + 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 type: object required: - spec diff --git a/config/iam/protected-resources/kustomization.yaml b/config/iam/protected-resources/kustomization.yaml index 6014fa4f..0e826922 100644 --- a/config/iam/protected-resources/kustomization.yaml +++ b/config/iam/protected-resources/kustomization.yaml @@ -22,6 +22,10 @@ resources: - backends.yaml - backendtrafficpolicies.yaml - backendtlspolicies.yaml + - connectoradvertisements.yaml + - connectorclasses.yaml + - connectors.yaml - httproutefilters.yaml + - leases.yaml - securitypolicies.yaml - trafficprotectionpolicies.yaml diff --git a/config/iam/protected-resources/leases.yaml b/config/iam/protected-resources/leases.yaml new file mode 100644 index 00000000..30876e3b --- /dev/null +++ b/config/iam/protected-resources/leases.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: iam.miloapis.com/v1alpha1 +kind: ProtectedResource +metadata: + name: coordination.k8s.io-lease +spec: + serviceRef: + name: "coordination.k8s.io" + kind: Lease + plural: leases + singular: lease + permissions: + - get + - update + - patch + parentResources: + - apiGroup: resourcemanager.miloapis.com + kind: Project diff --git a/config/iam/roles/connector-admin.yaml b/config/iam/roles/connector-admin.yaml new file mode 100644 index 00000000..0f1c5eb1 --- /dev/null +++ b/config/iam/roles/connector-admin.yaml @@ -0,0 +1,23 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: networking.datumapis.com-connector-admin + annotations: + kubernetes.io/display-name: Connector Admin + kubernetes.io/description: "Full access to connector resources" +spec: + launchStage: Beta + inheritedRoles: + - name: networking.datumapis.com-connector-viewer + includedPermissions: + - networking.datumapis.com/connectors.create + - networking.datumapis.com/connectors.update + - networking.datumapis.com/connectors.patch + - networking.datumapis.com/connectors.delete + - networking.datumapis.com/connectoradvertisements.create + - networking.datumapis.com/connectoradvertisements.update + - networking.datumapis.com/connectoradvertisements.patch + - networking.datumapis.com/connectoradvertisements.delete + - coordination.k8s.io/leases.update + - coordination.k8s.io/leases.patch + - coordination.k8s.io/leases.get diff --git a/config/iam/roles/connector-viewer.yaml b/config/iam/roles/connector-viewer.yaml new file mode 100644 index 00000000..61080466 --- /dev/null +++ b/config/iam/roles/connector-viewer.yaml @@ -0,0 +1,19 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: networking.datumapis.com-connector-viewer + annotations: + kubernetes.io/display-name: Connector Viewer + kubernetes.io/description: "View access to connector resources" +spec: + launchStage: Beta + includedPermissions: + - networking.datumapis.com/connectors.list + - networking.datumapis.com/connectors.get + - networking.datumapis.com/connectors.watch + - networking.datumapis.com/connectoradvertisements.list + - networking.datumapis.com/connectoradvertisements.get + - networking.datumapis.com/connectoradvertisements.watch + - networking.datumapis.com/connectorclasses.list + - networking.datumapis.com/connectorclasses.get + - networking.datumapis.com/connectorclasses.watch diff --git a/config/iam/roles/kustomization.yaml b/config/iam/roles/kustomization.yaml index fea2335f..627fb1f3 100644 --- a/config/iam/roles/kustomization.yaml +++ b/config/iam/roles/kustomization.yaml @@ -4,6 +4,8 @@ # Each role should have a corresponding configuration file in this directory. resources: + - connector-admin.yaml + - connector-viewer.yaml - gateway-admin.yaml - gateway-viewer.yaml - location-admin.yaml diff --git a/config/iam/roles/networking-admin.yaml b/config/iam/roles/networking-admin.yaml index 80016285..24ca519b 100644 --- a/config/iam/roles/networking-admin.yaml +++ b/config/iam/roles/networking-admin.yaml @@ -9,6 +9,7 @@ spec: launchStage: Beta inheritedRoles: - name: networking.datumapis.com-viewer + - name: networking.datumapis.com-connector-admin - name: networking.datumapis.com-gateway-admin - name: networking.datumapis.com-domain-admin includedPermissions: diff --git a/config/iam/roles/networking-viewer.yaml b/config/iam/roles/networking-viewer.yaml index 45804a36..3171c5ec 100644 --- a/config/iam/roles/networking-viewer.yaml +++ b/config/iam/roles/networking-viewer.yaml @@ -8,6 +8,7 @@ metadata: spec: launchStage: Beta inheritedRoles: + - name: networking.datumapis.com-connector-viewer - name: networking.datumapis.com-gateway-viewer - name: networking.datumapis.com-domain-viewer includedPermissions: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 8c6f5326..9557391c 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -38,6 +38,18 @@ rules: - get - patch - update +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - discovery.k8s.io resources: @@ -136,6 +148,8 @@ rules: - apiGroups: - networking.datumapis.com resources: + - connectoradvertisements + - connectors - domains - httpproxies - networkbindings @@ -156,6 +170,8 @@ rules: - apiGroups: - networking.datumapis.com resources: + - connectoradvertisements/finalizers + - connectors/finalizers - domains/finalizers - httpproxies/finalizers - networkbindings/finalizers @@ -170,6 +186,8 @@ rules: - apiGroups: - networking.datumapis.com resources: + - connectoradvertisements/status + - connectors/status - domains/status - httpproxies/status - networkbindings/status @@ -183,3 +201,11 @@ rules: - get - patch - update +- apiGroups: + - networking.datumapis.com + resources: + - connectorclasses + verbs: + - get + - list + - watch diff --git a/internal/config/config.go b/internal/config/config.go index 056a9b8b..94588c59 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,8 @@ type NetworkServicesOperator struct { HTTPProxy HTTPProxyConfig `json:"httpProxy"` + Connector ConnectorConfig `json:"connector"` + Discovery DiscoveryConfig `json:"discovery"` DownstreamResourceManagement DownstreamResourceManagementConfig `json:"downstreamResourceManagement"` @@ -57,6 +59,15 @@ type NetworkServicesOperator struct { DomainRegistration DomainRegistrationConfig `json:"domainRegistration"` } +// +k8s:deepcopy-gen=true +type ConnectorConfig struct { + // LeaseDurationSeconds is the number of seconds the connector lease is valid for. + // + // Defaults to 30 seconds. + // +default=30 + LeaseDurationSeconds int32 `json:"leaseDurationSeconds,omitempty"` +} + // +k8s:deepcopy-gen=true type WebhookServerConfig struct { diff --git a/internal/config/zz_generated.deepcopy.go b/internal/config/zz_generated.deepcopy.go index 2d8417f8..f8da69bb 100644 --- a/internal/config/zz_generated.deepcopy.go +++ b/internal/config/zz_generated.deepcopy.go @@ -95,6 +95,21 @@ func (in *ClusterSettingsValidationOptions) DeepCopy() *ClusterSettingsValidatio return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConnectorConfig) DeepCopyInto(out *ConnectorConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectorConfig. +func (in *ConnectorConfig) DeepCopy() *ConnectorConfig { + if in == nil { + return nil + } + out := new(ConnectorConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CorazaConfig) DeepCopyInto(out *CorazaConfig) { *out = *in @@ -397,6 +412,7 @@ func (in *NetworkServicesOperator) DeepCopyInto(out *NetworkServicesOperator) { in.WebhookServer.DeepCopyInto(&out.WebhookServer) in.Gateway.DeepCopyInto(&out.Gateway) out.HTTPProxy = in.HTTPProxy + out.Connector = in.Connector out.Discovery = in.Discovery out.DownstreamResourceManagement = in.DownstreamResourceManagement in.Redis.DeepCopyInto(&out.Redis) diff --git a/internal/config/zz_generated.defaults.go b/internal/config/zz_generated.defaults.go index eec42410..64e6dcd6 100644 --- a/internal/config/zz_generated.defaults.go +++ b/internal/config/zz_generated.defaults.go @@ -221,6 +221,9 @@ func SetObjectDefaults_NetworkServicesOperator(in *NetworkServicesOperator) { if in.HTTPProxy.GatewayClassName == "" { in.HTTPProxy.GatewayClassName = "datum-external-global-proxy" } + if in.Connector.LeaseDurationSeconds == 0 { + in.Connector.LeaseDurationSeconds = 30 + } SetDefaults_DiscoveryConfig(&in.Discovery) if in.Redis.DialTimeout == nil { if err := json.Unmarshal([]byte(`"5s"`), &in.Redis.DialTimeout); err != nil { diff --git a/internal/controller/connector_controller.go b/internal/controller/connector_controller.go new file mode 100644 index 00000000..73033990 --- /dev/null +++ b/internal/controller/connector_controller.go @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "fmt" + "math/rand" + "time" + + coordinationv1 "k8s.io/api/coordination/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder" + mchandler "sigs.k8s.io/multicluster-runtime/pkg/handler" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + networkingv1alpha1 "go.datum.net/network-services-operator/api/v1alpha1" + "go.datum.net/network-services-operator/internal/config" +) + +// ConnectorReconciler reconciles a Connector object +type ConnectorReconciler struct { + mgr mcmanager.Manager + Config config.NetworkServicesOperator +} + +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=connectors,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=connectors/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=connectors/finalizers,verbs=update +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=connectorclasses,verbs=get;list;watch +// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;watch;create;update;patch;delete + +func (r *ConnectorReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (_ ctrl.Result, err error) { + logger := log.FromContext(ctx, "cluster", req.ClusterName) + ctx = log.IntoContext(ctx, logger) + + cl, err := r.mgr.GetCluster(ctx, req.ClusterName) + if err != nil { + return ctrl.Result{}, err + } + + var connector networkingv1alpha1.Connector + if err := cl.GetClient().Get(ctx, req.NamespacedName, &connector); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + if !connector.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + logger.Info("reconciling connector") + defer logger.Info("reconcile complete") + + originalStatus := connector.Status.DeepCopy() + readyCondition := apimeta.FindStatusCondition(connector.Status.Conditions, networkingv1alpha1.ConnectorConditionReady) + if readyCondition == nil { + readyCondition = &metav1.Condition{ + Type: networkingv1alpha1.ConnectorConditionReady, + } + } + readyCondition.ObservedGeneration = connector.Generation + + acceptedCondition := apimeta.FindStatusCondition(connector.Status.Conditions, networkingv1alpha1.ConnectorConditionAccepted) + if acceptedCondition == nil { + acceptedCondition = &metav1.Condition{ + Type: networkingv1alpha1.ConnectorConditionAccepted, + } + } + acceptedCondition.ObservedGeneration = connector.Generation + + if connector.Spec.ConnectorClassName == "" { + acceptedCondition.Status = metav1.ConditionFalse + acceptedCondition.Reason = networkingv1alpha1.ConnectorReasonPending + acceptedCondition.Message = "connectorClassName is required" + } else { + var connectorClass networkingv1alpha1.ConnectorClass + if err := cl.GetClient().Get(ctx, client.ObjectKey{Name: connector.Spec.ConnectorClassName}, &connectorClass); err != nil { + if apierrors.IsNotFound(err) { + acceptedCondition.Status = metav1.ConditionFalse + acceptedCondition.Reason = networkingv1alpha1.ConnectorReasonConnectorClassNotFound + acceptedCondition.Message = fmt.Sprintf("ConnectorClass %q not found", connector.Spec.ConnectorClassName) + } else { + return ctrl.Result{}, err + } + } else { + acceptedCondition.Status = metav1.ConditionTrue + acceptedCondition.Reason = networkingv1alpha1.ConnectorReasonAccepted + acceptedCondition.Message = "Connector class resolved" + } + } + + apimeta.SetStatusCondition(&connector.Status.Conditions, *acceptedCondition) + apimeta.SetStatusCondition(&connector.Status.Conditions, *readyCondition) + + leaseDurationSeconds := r.connectorLeaseDurationSeconds() + if connector.Status.LeaseRef == nil || connector.Status.LeaseRef.Name == "" { + lease := &coordinationv1.Lease{ + ObjectMeta: metav1.ObjectMeta{ + Name: connector.Name, + Namespace: connector.Namespace, + }, + } + + if _, err := controllerutil.CreateOrUpdate(ctx, cl.GetClient(), lease, func() error { + if err := controllerutil.SetControllerReference(&connector, lease, cl.GetScheme()); err != nil { + return err + } + if lease.Spec.LeaseDurationSeconds == nil || *lease.Spec.LeaseDurationSeconds == 0 { + lease.Spec.LeaseDurationSeconds = &leaseDurationSeconds + } + return nil + }); err != nil { + return ctrl.Result{}, err + } + + connector.Status.LeaseRef = &corev1.LocalObjectReference{Name: lease.Name} + } + + leaseStatus, err := r.connectorLeaseReady(ctx, cl.GetClient(), &connector) + if err != nil { + return ctrl.Result{}, err + } + if leaseStatus.ready { + readyCondition.Status = metav1.ConditionTrue + readyCondition.Reason = networkingv1alpha1.ConnectorReasonReady + readyCondition.Message = "The connector is ready to tunnel traffic." + } else { + readyCondition.Status = metav1.ConditionFalse + readyCondition.Reason = networkingv1alpha1.ConnectorReasonNotReady + readyCondition.Message = leaseStatus.message + } + + apimeta.SetStatusCondition(&connector.Status.Conditions, *readyCondition) + + if !equality.Semantic.DeepEqual(*originalStatus, connector.Status) { + if statusErr := cl.GetClient().Status().Update(ctx, &connector); statusErr != nil { + return ctrl.Result{}, fmt.Errorf("failed updating connector status: %w", statusErr) + } + } + + if leaseStatus.requeueAfter != nil { + return ctrl.Result{RequeueAfter: *leaseStatus.requeueAfter}, nil + } + return ctrl.Result{}, nil +} + +func (r *ConnectorReconciler) connectorLeaseDurationSeconds() int32 { + if r.Config.Connector.LeaseDurationSeconds > 0 { + return r.Config.Connector.LeaseDurationSeconds + } + return 30 +} + +type connectorLeaseStatus struct { + ready bool + requeueAfter *time.Duration + message string +} + +func (r *ConnectorReconciler) connectorLeaseReady(ctx context.Context, cl client.Client, connector *networkingv1alpha1.Connector) (connectorLeaseStatus, error) { + if connector.Status.LeaseRef == nil || connector.Status.LeaseRef.Name == "" { + return connectorLeaseStatus{message: "Connector lease has not been created yet."}, nil + } + + var lease coordinationv1.Lease + if err := cl.Get(ctx, client.ObjectKey{Namespace: connector.Namespace, Name: connector.Status.LeaseRef.Name}, &lease); err != nil { + if apierrors.IsNotFound(err) { + return connectorLeaseStatus{message: "Connector lease not found. Agent may be offline."}, nil + } + return connectorLeaseStatus{}, fmt.Errorf("failed to load connector lease: %w", err) + } + + if lease.Spec.RenewTime == nil || lease.Spec.LeaseDurationSeconds == nil { + return connectorLeaseStatus{message: "Connector lease has not been renewed yet."}, nil + } + + expiryDuration := time.Duration(*lease.Spec.LeaseDurationSeconds) * time.Second + expiresAt := lease.Spec.RenewTime.Add(expiryDuration) + if time.Now().After(expiresAt) { + return connectorLeaseStatus{message: "Connector lease has expired. Agent may be offline."}, nil + } + + requeueAfter := time.Until(expiresAt) + leaseJitter(expiryDuration) + return connectorLeaseStatus{ready: true, requeueAfter: &requeueAfter}, nil +} + +func leaseJitter(base time.Duration) time.Duration { + if base <= 0 { + return 0 + } + jitterMax := base / 10 + if jitterMax <= 0 { + return 0 + } + return time.Duration(rand.Int63n(int64(jitterMax))) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ConnectorReconciler) SetupWithManager(mgr mcmanager.Manager) error { + r.mgr = mgr + + return mcbuilder.ControllerManagedBy(mgr). + For(&networkingv1alpha1.Connector{}). + Watches( + &coordinationv1.Lease{}, + mchandler.EnqueueRequestForOwner(&networkingv1alpha1.Connector{}, handler.OnlyControllerOwner()), + ). + Named("connector"). + Complete(r) +} diff --git a/internal/controller/connector_controllers_test.go b/internal/controller/connector_controllers_test.go new file mode 100644 index 00000000..7e2f2ebb --- /dev/null +++ b/internal/controller/connector_controllers_test.go @@ -0,0 +1,238 @@ +package controller + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + coordinationv1 "k8s.io/api/coordination/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + networkingv1alpha1 "go.datum.net/network-services-operator/api/v1alpha1" +) + +func TestConnectorReconcile(t *testing.T) { + log.SetLogger(zap.New(zap.UseDevMode(true))) + + tests := []struct { + name string + connector *networkingv1alpha1.Connector + connectorClass *networkingv1alpha1.ConnectorClass + lease *coordinationv1.Lease + wantStatus metav1.ConditionStatus + wantReason string + wantReady metav1.ConditionStatus + wantReadyReason string + }{ + { + name: "connector class resolved", + connector: &networkingv1alpha1.Connector{ + ObjectMeta: metav1.ObjectMeta{Name: "connector", Namespace: "default"}, + Spec: networkingv1alpha1.ConnectorSpec{ + ConnectorClassName: "datum-connect", + }, + }, + connectorClass: &networkingv1alpha1.ConnectorClass{ + ObjectMeta: metav1.ObjectMeta{Name: "datum-connect"}, + }, + wantStatus: metav1.ConditionTrue, + wantReason: networkingv1alpha1.ConnectorReasonAccepted, + wantReady: metav1.ConditionFalse, + wantReadyReason: networkingv1alpha1.ConnectorReasonNotReady, + }, + { + name: "connector class missing", + connector: &networkingv1alpha1.Connector{ + ObjectMeta: metav1.ObjectMeta{Name: "connector", Namespace: "default"}, + Spec: networkingv1alpha1.ConnectorSpec{ + ConnectorClassName: "missing", + }, + }, + wantStatus: metav1.ConditionFalse, + wantReason: networkingv1alpha1.ConnectorReasonConnectorClassNotFound, + wantReady: metav1.ConditionFalse, + wantReadyReason: networkingv1alpha1.ConnectorReasonNotReady, + }, + { + name: "connector lease ready", + connector: &networkingv1alpha1.Connector{ + ObjectMeta: metav1.ObjectMeta{Name: "connector", Namespace: "default"}, + Spec: networkingv1alpha1.ConnectorSpec{ + ConnectorClassName: "datum-connect", + }, + }, + connectorClass: &networkingv1alpha1.ConnectorClass{ + ObjectMeta: metav1.ObjectMeta{Name: "datum-connect"}, + }, + lease: &coordinationv1.Lease{ + ObjectMeta: metav1.ObjectMeta{Name: "connector", Namespace: "default"}, + Spec: coordinationv1.LeaseSpec{ + LeaseDurationSeconds: ptr.To[int32](30), + RenewTime: &metav1.MicroTime{Time: time.Now()}, + }, + }, + wantStatus: metav1.ConditionTrue, + wantReason: networkingv1alpha1.ConnectorReasonAccepted, + wantReady: metav1.ConditionTrue, + wantReadyReason: networkingv1alpha1.ConnectorReasonReady, + }, + { + name: "connector lease expired", + connector: &networkingv1alpha1.Connector{ + ObjectMeta: metav1.ObjectMeta{Name: "connector", Namespace: "default"}, + Spec: networkingv1alpha1.ConnectorSpec{ + ConnectorClassName: "datum-connect", + }, + }, + connectorClass: &networkingv1alpha1.ConnectorClass{ + ObjectMeta: metav1.ObjectMeta{Name: "datum-connect"}, + }, + lease: &coordinationv1.Lease{ + ObjectMeta: metav1.ObjectMeta{Name: "connector", Namespace: "default"}, + Spec: coordinationv1.LeaseSpec{ + LeaseDurationSeconds: ptr.To[int32](30), + RenewTime: &metav1.MicroTime{Time: time.Now().Add(-time.Minute)}, + }, + }, + wantStatus: metav1.ConditionTrue, + wantReason: networkingv1alpha1.ConnectorReasonAccepted, + wantReady: metav1.ConditionFalse, + wantReadyReason: networkingv1alpha1.ConnectorReasonNotReady, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testScheme := runtime.NewScheme() + assert.NoError(t, scheme.AddToScheme(testScheme)) + assert.NoError(t, coordinationv1.AddToScheme(testScheme)) + assert.NoError(t, networkingv1alpha1.AddToScheme(testScheme)) + + builder := fake.NewClientBuilder().WithScheme(testScheme).WithObjects(tt.connector) + if tt.connectorClass != nil { + builder = builder.WithObjects(tt.connectorClass) + } + if tt.lease != nil { + builder = builder.WithObjects(tt.lease) + } + builder = builder.WithStatusSubresource(tt.connector) + cl := builder.Build() + + reconciler := &ConnectorReconciler{mgr: &fakeMockManager{cl: cl}} + req := mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(tt.connector), + }, + ClusterName: "single", + } + + _, err := reconciler.Reconcile(context.Background(), req) + assert.NoError(t, err) + + var updated networkingv1alpha1.Connector + assert.NoError(t, cl.Get(context.Background(), client.ObjectKeyFromObject(tt.connector), &updated)) + + condition := apimeta.FindStatusCondition(updated.Status.Conditions, networkingv1alpha1.ConnectorConditionAccepted) + if assert.NotNil(t, condition) { + assert.Equal(t, tt.wantStatus, condition.Status) + assert.Equal(t, tt.wantReason, condition.Reason) + } + + readyCondition := apimeta.FindStatusCondition(updated.Status.Conditions, networkingv1alpha1.ConnectorConditionReady) + if assert.NotNil(t, readyCondition) { + assert.Equal(t, tt.wantReady, readyCondition.Status) + assert.Equal(t, tt.wantReadyReason, readyCondition.Reason) + } + }) + } +} + +func TestConnectorAdvertisementReconcile(t *testing.T) { + log.SetLogger(zap.New(zap.UseDevMode(true))) + + tests := []struct { + name string + advertisement *networkingv1alpha1.ConnectorAdvertisement + connector *networkingv1alpha1.Connector + wantStatus metav1.ConditionStatus + wantReason string + }{ + { + name: "connector reference resolved", + advertisement: &networkingv1alpha1.ConnectorAdvertisement{ + ObjectMeta: metav1.ObjectMeta{Name: "ad", Namespace: "default"}, + Spec: networkingv1alpha1.ConnectorAdvertisementSpec{ + ConnectorRef: networkingv1alpha1.LocalConnectorReference{Name: "connector"}, + }, + }, + connector: &networkingv1alpha1.Connector{ + ObjectMeta: metav1.ObjectMeta{Name: "connector", Namespace: "default"}, + Spec: networkingv1alpha1.ConnectorSpec{ConnectorClassName: "datum-connect"}, + }, + wantStatus: metav1.ConditionTrue, + wantReason: networkingv1alpha1.ConnectorAdvertisementReasonAccepted, + }, + { + name: "connector reference missing", + advertisement: &networkingv1alpha1.ConnectorAdvertisement{ + ObjectMeta: metav1.ObjectMeta{Name: "ad", Namespace: "default"}, + Spec: networkingv1alpha1.ConnectorAdvertisementSpec{ + ConnectorRef: networkingv1alpha1.LocalConnectorReference{Name: "missing"}, + }, + }, + wantStatus: metav1.ConditionFalse, + wantReason: networkingv1alpha1.ConnectorAdvertisementReasonConnectorNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testScheme := runtime.NewScheme() + assert.NoError(t, scheme.AddToScheme(testScheme)) + assert.NoError(t, networkingv1alpha1.AddToScheme(testScheme)) + + builder := fake.NewClientBuilder(). + WithScheme(testScheme). + WithObjects(tt.advertisement). + WithStatusSubresource(tt.advertisement) + if tt.connector != nil { + builder = builder.WithObjects(tt.connector) + } + cl := builder.Build() + + reconciler := &ConnectorAdvertisementReconciler{mgr: &fakeMockManager{cl: cl}} + req := mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(tt.advertisement), + }, + ClusterName: "single", + } + + _, err := reconciler.Reconcile(context.Background(), req) + assert.NoError(t, err) + + var updated networkingv1alpha1.ConnectorAdvertisement + assert.NoError(t, cl.Get(context.Background(), client.ObjectKeyFromObject(tt.advertisement), &updated)) + + condition := apimeta.FindStatusCondition(updated.Status.Conditions, networkingv1alpha1.ConnectorAdvertisementConditionAccepted) + if assert.NotNil(t, condition) { + assert.Equal(t, tt.wantStatus, condition.Status) + assert.Equal(t, tt.wantReason, condition.Reason) + } + if tt.connector != nil { + assert.True(t, metav1.IsControlledBy(&updated, tt.connector)) + } + }) + } +} diff --git a/internal/controller/connectoradvertisement_controller.go b/internal/controller/connectoradvertisement_controller.go new file mode 100644 index 00000000..cee9fd60 --- /dev/null +++ b/internal/controller/connectoradvertisement_controller.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + networkingv1alpha1 "go.datum.net/network-services-operator/api/v1alpha1" +) + +// ConnectorAdvertisementReconciler reconciles a ConnectorAdvertisement object +type ConnectorAdvertisementReconciler struct { + mgr mcmanager.Manager +} + +const connectorAdvertisementConnectorRefIndex = "spec.connectorRef.name" + +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=connectoradvertisements,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=connectoradvertisements/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=connectoradvertisements/finalizers,verbs=update +// +kubebuilder:rbac:groups=networking.datumapis.com,resources=connectors,verbs=get;list;watch + +func (r *ConnectorAdvertisementReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (_ ctrl.Result, err error) { + logger := log.FromContext(ctx, "cluster", req.ClusterName) + ctx = log.IntoContext(ctx, logger) + + cl, err := r.mgr.GetCluster(ctx, req.ClusterName) + if err != nil { + return ctrl.Result{}, err + } + + var advertisement networkingv1alpha1.ConnectorAdvertisement + if err := cl.GetClient().Get(ctx, req.NamespacedName, &advertisement); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + if !advertisement.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + logger.Info("reconciling connectoradvertisement") + defer logger.Info("reconcile complete") + + originalStatus := advertisement.Status.DeepCopy() + + acceptedCondition := apimeta.FindStatusCondition(advertisement.Status.Conditions, networkingv1alpha1.ConnectorAdvertisementConditionAccepted) + if acceptedCondition == nil { + acceptedCondition = &metav1.Condition{ + Type: networkingv1alpha1.ConnectorAdvertisementConditionAccepted, + } + } + acceptedCondition.ObservedGeneration = advertisement.Generation + + if advertisement.Spec.ConnectorRef.Name == "" { + acceptedCondition.Status = metav1.ConditionFalse + acceptedCondition.Reason = networkingv1alpha1.ConnectorAdvertisementReasonPending + acceptedCondition.Message = "connectorRef.name is required" + apimeta.SetStatusCondition(&advertisement.Status.Conditions, *acceptedCondition) + } else { + connector := &networkingv1alpha1.Connector{} + connectorKey := client.ObjectKey{Namespace: advertisement.Namespace, Name: advertisement.Spec.ConnectorRef.Name} + if err := cl.GetClient().Get(ctx, connectorKey, connector); err != nil { + if apierrors.IsNotFound(err) { + acceptedCondition.Status = metav1.ConditionFalse + acceptedCondition.Reason = networkingv1alpha1.ConnectorAdvertisementReasonConnectorNotFound + acceptedCondition.Message = fmt.Sprintf("Connector %q not found", advertisement.Spec.ConnectorRef.Name) + apimeta.SetStatusCondition(&advertisement.Status.Conditions, *acceptedCondition) + } else { + return ctrl.Result{}, err + } + } else { + if !metav1.IsControlledBy(&advertisement, connector) { + if err := controllerutil.SetControllerReference(connector, &advertisement, cl.GetScheme()); err != nil { + return ctrl.Result{}, err + } + if err := cl.GetClient().Update(ctx, &advertisement); err != nil { + return ctrl.Result{}, err + } + } + acceptedCondition.Status = metav1.ConditionTrue + acceptedCondition.Reason = networkingv1alpha1.ConnectorAdvertisementReasonAccepted + acceptedCondition.Message = "Connector reference resolved" + apimeta.SetStatusCondition(&advertisement.Status.Conditions, *acceptedCondition) + } + } + + if !equality.Semantic.DeepEqual(*originalStatus, advertisement.Status) { + if statusErr := cl.GetClient().Status().Update(ctx, &advertisement); statusErr != nil { + return ctrl.Result{}, fmt.Errorf("failed updating connectoradvertisement status: %w", statusErr) + } + } + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ConnectorAdvertisementReconciler) SetupWithManager(mgr mcmanager.Manager) error { + r.mgr = mgr + if err := mgr.GetFieldIndexer().IndexField( + context.Background(), + &networkingv1alpha1.ConnectorAdvertisement{}, + connectorAdvertisementConnectorRefIndex, + func(obj client.Object) []string { + advertisement, ok := obj.(*networkingv1alpha1.ConnectorAdvertisement) + if !ok || advertisement.Spec.ConnectorRef.Name == "" { + return nil + } + return []string{advertisement.Spec.ConnectorRef.Name} + }, + ); err != nil { + return err + } + return mcbuilder.ControllerManagedBy(mgr). + For(&networkingv1alpha1.ConnectorAdvertisement{}). + Watches( + &networkingv1alpha1.Connector{}, + func(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] { + return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []mcreconcile.Request { + logger := log.FromContext(ctx) + connector, ok := obj.(*networkingv1alpha1.Connector) + if !ok { + return nil + } + + var ads networkingv1alpha1.ConnectorAdvertisementList + if err := cl.GetClient().List( + ctx, + &ads, + client.InNamespace(connector.Namespace), + client.MatchingFields{connectorAdvertisementConnectorRefIndex: connector.Name}, + ); err != nil { + logger.Error(err, "failed to list connectoradvertisements", "connector", connector.Name) + return nil + } + + requests := make([]mcreconcile.Request, 0, len(ads.Items)) + for i := range ads.Items { + requests = append(requests, mcreconcile.Request{ + ClusterName: clusterName, + Request: ctrl.Request{ + NamespacedName: client.ObjectKeyFromObject(&ads.Items[i]), + }, + }) + } + + return requests + }) + }, + ). + Named("connectoradvertisement"). + Complete(r) +}