Repository: metallb/metallb
Pull request #2667 (2025-01-27 02:07:30)

Chore(deps): bump actions/stale from 9.0.0 to 9.1.0
Bumps [actions/stale](https://github.com/actions/stale) from 9.0.0 to 9.1.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/actions/stale/releases">actions/stale's releases</a>.</em></p>
<blockquote>
<h2>v9.1.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Documentation update by <a href="https://github.com/Marukome0743"><code>@​Marukome0743</code></a> in <a href="https://redirect.github.com/actions/stale/pull/1116">actions/stale#1116</a></li>
<li>Add workflow file for publishing releases to immutable action package by <a href="https://github.com/Jcambass"><code>@​Jcambass</code></a> in <a href="https://redirect.github.com/actions/stale/pull/1179">actions/stale#1179</a></li>
<li>Update undici from 5.28.2 to 5.28.4 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/actions/stale/pull/1150">actions/stale#1150</a></li>
<li>Update actions/checkout from 3 to 4 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/actions/stale/pull/1091">actions/stale#1091</a></li>
<li>Update actions/publish-action from 0.2.2 to 0.3.0 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/actions/stale/pull/1147">actions/stale#1147</a></li>
<li>Update ts-jest from 29.1.1 to 29.2.5 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/actions/stale/pull/1175">actions/stale#1175</a></li>
<li>Update <code>@​actions/core</code> from 1.10.1 to 1.11.1 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/actions/stale/pull/1191">actions/stale#1191</a></li>
<li>Update <code>@​types/jest</code> from 29.5.11 to 29.5.14 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/actions/stale/pull/1193">actions/stale#1193</a></li>
<li>Update <code>@​actions/cache</code> from 3.2.2 to 4.0.0 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/actions/stale/pull/1194">actions/stale#1194</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Marukome0743"><code>@​Marukome0743</code></a> made their first contribution in <a href="https://redirect.github.com/actions/stale/pull/1116">actions/stale#1116</a></li>
<li><a href="https://github.com/Jcambass"><code>@​Jcambass</code></a> made their first contribution in <a href="https://redirect.github.com/actions/stale/pull/1179">actions/stale#1179</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/actions/stale/compare/v9...v9.1.0">https://github.com/actions/stale/compare/v9...v9.1.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/actions/stale/commit/5bef64f19d7facfb25b37b414482c7164d639639"><code>5bef64f</code></a> build(deps): bump <code>@​actions/cache</code> from 3.2.2 to 4.0.0 (<a href="https://redirect.github.com/actions/stale/issues/1194">#1194</a>)</li>
<li><a href="https://github.com/actions/stale/commit/fa77dfddd04682b7d96dbc4e016318e681fdc10e"><code>fa77dfd</code></a> build(deps-dev): bump <code>@​types/jest</code> from 29.5.11 to 29.5.14 (<a href="https://redirect.github.com/actions/stale/issues/1193">#1193</a>)</li>
<li><a href="https://github.com/actions/stale/commit/f04443dce335c74ba15c65f4cbb3688e6cb6a6ec"><code>f04443d</code></a> build(deps): bump <code>@​actions/core</code> from 1.10.1 to 1.11.1 (<a href="https://redirect.github.com/actions/stale/issues/1191">#1191</a>)</li>
<li><a href="https://github.com/actions/stale/commit/5c715b0513651880806e14d529f014b12fdd50eb"><code>5c715b0</code></a> build(deps-dev): bump ts-jest from 29.1.1 to 29.2.5 (<a href="https://redirect.github.com/actions/stale/issues/1175">#1175</a>)</li>
<li><a href="https://github.com/actions/stale/commit/f69122271d990fd11f5594ccff2296f00ff59b49"><code>f691222</code></a> build(deps): bump actions/publish-action from 0.2.2 to 0.3.0 (<a href="https://redirect.github.com/actions/stale/issues/1147">#1147</a>)</li>
<li><a href="https://github.com/actions/stale/commit/df990c2cf5ae92c90653c9485d6882a0a09feac7"><code>df990c2</code></a> build(deps): bump actions/checkout from 3 to 4 (<a href="https://redirect.github.com/actions/stale/issues/1091">#1091</a>)</li>
<li><a href="https://github.com/actions/stale/commit/6e472ce44ab4197b0154601c59c54a75b73b340b"><code>6e472ce</code></a> Merge pull request <a href="https://redirect.github.com/actions/stale/issues/1179">#1179</a> from actions/Jcambass-patch-1</li>
<li><a href="https://github.com/actions/stale/commit/d10ba64261d965f75165f74c55cd3ffbf690d442"><code>d10ba64</code></a> Merge pull request <a href="https://redirect.github.com/actions/stale/issues/1150">#1150</a> from actions/dependabot/npm_and_yarn/undici-5.28.4</li>
<li><a href="https://github.com/actions/stale/commit/bbf3da5f64eebd003932d93293857400f7f7e18d"><code>bbf3da5</code></a> resolve check failures</li>
<li><a href="https://github.com/actions/stale/commit/6a2e61d18b155e538f85ef1bf7bd0470775e9703"><code>6a2e61d</code></a> Add workflow file for publishing releases to immutable action package</li>
<li>Additional commits viewable in <a href="https://github.com/actions/stale/compare/v9.0.0...v9.1.0">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/stale&package-manager=github_actions&previous-version=9.0.0&new-version=9.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 93bd4c9a..1f17dffb 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -32,7 +32,7 @@ jobs:
       - name: Checkout Source
         uses: actions/checkout@v4
       - name: Run Gosec Security Scanner
-        uses: securego/gosec@v2.21.2
+        uses: securego/gosec@v2.22.0
         with:
           args: -exclude-dir e2etest -severity medium ./...
       - name: Golang Vulncheck
diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml
index 1f360e18..57268ae9 100644
--- a/.github/workflows/stale.yaml
+++ b/.github/workflows/stale.yaml
@@ -9,7 +9,7 @@ jobs:
   stale:
     runs-on: ubuntu-22.04
     steps:
-    - uses: actions/stale@v9.0.0
+    - uses: actions/stale@v9.1.0
       with:
         repo-token: ${{ secrets.GITHUB_TOKEN }}
         days-before-stale: 30
diff --git a/api/v1beta1/servicebgpstatus_types.go b/api/v1beta1/servicebgpstatus_types.go
new file mode 100644
index 00000000..a2e1f936
--- /dev/null
+++ b/api/v1beta1/servicebgpstatus_types.go
@@ -0,0 +1,74 @@
+/*
+
+
+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 v1beta1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// +kubebuilder:object:generate=true
+
+// MetalLBServiceBGPStatus defines the observed state of ServiceBGPStatus.
+type MetalLBServiceBGPStatus struct {
+	// Node indicates the node announcing the service.
+	// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
+	Node string `json:"node,omitempty"`
+
+	// ServiceName indicates the service this status represents.
+	// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
+	ServiceName string `json:"serviceName,omitempty"`
+
+	// ServiceNamespace indicates the namespace of the service.
+	// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
+	ServiceNamespace string `json:"serviceNamespace,omitempty"`
+
+	// Peers indicate the BGP peers for which the service is configured to be advertised to.
+	// The service being actually advertised to a given peer depends on the session state and is not indicated here.
+	Peers []string `json:"peers,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:storageversion
+// +kubebuilder:printcolumn:name="Node",type=string,JSONPath=`.status.node`
+// +kubebuilder:printcolumn:name="Service Name",type=string,JSONPath=`.status.serviceName`
+// +kubebuilder:printcolumn:name="Service Namespace",type=string,JSONPath=`.status.serviceNamespace`
+// ServiceBGPStatus exposes the BGP peers a service is configured to be advertised to, per relevant node.
+type ServiceBGPStatus struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   ServiceBGPStatusSpec    `json:"spec,omitempty"`
+	Status MetalLBServiceBGPStatus `json:"status,omitempty"`
+}
+
+// ServiceBGPStatusSpec defines the desired state of ServiceBGPStatus.
+type ServiceBGPStatusSpec struct {
+}
+
+// +kubebuilder:object:root=true
+
+// ServiceBGPStatusList contains a list of ServiceBGPStatus.
+type ServiceBGPStatusList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []ServiceBGPStatus `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&ServiceBGPStatus{}, &ServiceBGPStatusList{})
+}
diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go
index 81a7382c..33e52e14 100644
--- a/api/v1beta1/zz_generated.deepcopy.go
+++ b/api/v1beta1/zz_generated.deepcopy.go
@@ -736,6 +736,26 @@ func (in *MatchExpression) DeepCopy() *MatchExpression {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MetalLBServiceBGPStatus) DeepCopyInto(out *MetalLBServiceBGPStatus) {
+	*out = *in
+	if in.Peers != nil {
+		in, out := &in.Peers, &out.Peers
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetalLBServiceBGPStatus.
+func (in *MetalLBServiceBGPStatus) DeepCopy() *MetalLBServiceBGPStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(MetalLBServiceBGPStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *MetalLBServiceL2Status) DeepCopyInto(out *MetalLBServiceL2Status) {
 	*out = *in
@@ -819,6 +839,80 @@ func (in *ServiceAllocation) DeepCopy() *ServiceAllocation {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ServiceBGPStatus) DeepCopyInto(out *ServiceBGPStatus) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	out.Spec = in.Spec
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceBGPStatus.
+func (in *ServiceBGPStatus) DeepCopy() *ServiceBGPStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(ServiceBGPStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ServiceBGPStatus) 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 *ServiceBGPStatusList) DeepCopyInto(out *ServiceBGPStatusList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]ServiceBGPStatus, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceBGPStatusList.
+func (in *ServiceBGPStatusList) DeepCopy() *ServiceBGPStatusList {
+	if in == nil {
+		return nil
+	}
+	out := new(ServiceBGPStatusList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ServiceBGPStatusList) 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 *ServiceBGPStatusSpec) DeepCopyInto(out *ServiceBGPStatusSpec) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceBGPStatusSpec.
+func (in *ServiceBGPStatusSpec) DeepCopy() *ServiceBGPStatusSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(ServiceBGPStatusSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ServiceL2Status) DeepCopyInto(out *ServiceL2Status) {
 	*out = *in
diff --git a/charts/metallb/charts/crds/templates/crds.yaml b/charts/metallb/charts/crds/templates/crds.yaml
index 8f241477..28b74188 100644
--- a/charts/metallb/charts/crds/templates/crds.yaml
+++ b/charts/metallb/charts/crds/templates/crds.yaml
@@ -1132,6 +1132,91 @@ spec:
 ---
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.3
+  name: servicebgpstatuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceBGPStatus
+    listKind: ServiceBGPStatusList
+    plural: servicebgpstatuses
+    singular: servicebgpstatus
+  scope: Namespaced
+  versions:
+    - additionalPrinterColumns:
+        - jsonPath: .status.node
+          name: Node
+          type: string
+        - jsonPath: .status.serviceName
+          name: Service Name
+          type: string
+        - jsonPath: .status.serviceNamespace
+          name: Service Namespace
+          type: string
+      name: v1beta1
+      schema:
+        openAPIV3Schema:
+          description: ServiceBGPStatus exposes the BGP peers a service is configured to be advertised to, per relevant node.
+          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: ServiceBGPStatusSpec defines the desired state of ServiceBGPStatus.
+              type: object
+            status:
+              description: MetalLBServiceBGPStatus defines the observed state of ServiceBGPStatus.
+              properties:
+                node:
+                  description: Node indicates the node announcing the service.
+                  type: string
+                  x-kubernetes-validations:
+                    - message: Value is immutable
+                      rule: self == oldSelf
+                peers:
+                  description: |-
+                    Peers indicate the BGP peers for which the service is configured to be advertised to.
+                    The service being actually advertised to a given peer depends on the session state and is not indicated here.
+                  items:
+                    type: string
+                  type: array
+                serviceName:
+                  description: ServiceName indicates the service this status represents.
+                  type: string
+                  x-kubernetes-validations:
+                    - message: Value is immutable
+                      rule: self == oldSelf
+                serviceNamespace:
+                  description: ServiceNamespace indicates the namespace of the service.
+                  type: string
+                  x-kubernetes-validations:
+                    - message: Value is immutable
+                      rule: self == oldSelf
+              type: object
+          type: object
+      served: true
+      storage: true
+      subresources:
+        status: {}
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
 metadata:
   annotations:
     controller-gen.kubebuilder.io/version: v0.16.3
diff --git a/charts/metallb/templates/rbac.yaml b/charts/metallb/templates/rbac.yaml
index 10ffbd8a..b174cba3 100644
--- a/charts/metallb/templates/rbac.yaml
+++ b/charts/metallb/templates/rbac.yaml
@@ -110,6 +110,9 @@ rules:
 - apiGroups: ["metallb.io"]
   resources: ["communities"]
   verbs: ["get", "list", "watch"]
+- apiGroups: ["metallb.io"]
+  resources: ["servicebgpstatuses","servicebgpstatuses/status"]
+  verbs: ["*"]
 {{- end }}
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/config/crd/bases/metallb.io_servicebgpstatuses.yaml b/config/crd/bases/metallb.io_servicebgpstatuses.yaml
new file mode 100644
index 00000000..af055c46
--- /dev/null
+++ b/config/crd/bases/metallb.io_servicebgpstatuses.yaml
@@ -0,0 +1,86 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.3
+  name: servicebgpstatuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceBGPStatus
+    listKind: ServiceBGPStatusList
+    plural: servicebgpstatuses
+    singular: servicebgpstatus
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Node
+      type: string
+    - jsonPath: .status.serviceName
+      name: Service Name
+      type: string
+    - jsonPath: .status.serviceNamespace
+      name: Service Namespace
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceBGPStatus exposes the BGP peers a service is configured
+          to be advertised to, per relevant node.
+        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: ServiceBGPStatusSpec defines the desired state of ServiceBGPStatus.
+            type: object
+          status:
+            description: MetalLBServiceBGPStatus defines the observed state of ServiceBGPStatus.
+            properties:
+              node:
+                description: Node indicates the node announcing the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              peers:
+                description: |-
+                  Peers indicate the BGP peers for which the service is configured to be advertised to.
+                  The service being actually advertised to a given peer depends on the session state and is not indicated here.
+                items:
+                  type: string
+                type: array
+              serviceName:
+                description: ServiceName indicates the service this status represents.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              serviceNamespace:
+                description: ServiceNamespace indicates the namespace of the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index d5641373..72776623 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -8,6 +8,7 @@ resources:
 - bases/metallb.io_l2advertisements.yaml
 - bases/metallb.io_communities.yaml
 - bases/metallb.io_servicel2statuses.yaml
+- bases/metallb.io_servicebgpstatuses.yaml
 
 patches:
 - path: patches/crd-conversion-patch-bgppeers.yaml
diff --git a/config/manifests/metallb-frr-k8s-prometheus.yaml b/config/manifests/metallb-frr-k8s-prometheus.yaml
index 9ee41b36..dc5003d5 100644
--- a/config/manifests/metallb-frr-k8s-prometheus.yaml
+++ b/config/manifests/metallb-frr-k8s-prometheus.yaml
@@ -1721,6 +1721,92 @@ spec:
 ---
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.3
+  name: servicebgpstatuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceBGPStatus
+    listKind: ServiceBGPStatusList
+    plural: servicebgpstatuses
+    singular: servicebgpstatus
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Node
+      type: string
+    - jsonPath: .status.serviceName
+      name: Service Name
+      type: string
+    - jsonPath: .status.serviceNamespace
+      name: Service Namespace
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceBGPStatus exposes the BGP peers a service is configured
+          to be advertised to, per relevant node.
+        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: ServiceBGPStatusSpec defines the desired state of ServiceBGPStatus.
+            type: object
+          status:
+            description: MetalLBServiceBGPStatus defines the observed state of ServiceBGPStatus.
+            properties:
+              node:
+                description: Node indicates the node announcing the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              peers:
+                description: |-
+                  Peers indicate the BGP peers for which the service is configured to be advertised to.
+                  The service being actually advertised to a given peer depends on the session state and is not indicated here.
+                items:
+                  type: string
+                type: array
+              serviceName:
+                description: ServiceName indicates the service this status represents.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              serviceNamespace:
+                description: ServiceNamespace indicates the namespace of the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
 metadata:
   annotations:
     controller-gen.kubebuilder.io/version: v0.16.3
@@ -2019,6 +2105,13 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - servicebgpstatuses
+  - servicebgpstatuses/status
+  verbs:
+  - '*'
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: Role
diff --git a/config/manifests/metallb-frr-k8s.yaml b/config/manifests/metallb-frr-k8s.yaml
index a4f67342..341aacfe 100644
--- a/config/manifests/metallb-frr-k8s.yaml
+++ b/config/manifests/metallb-frr-k8s.yaml
@@ -1721,6 +1721,92 @@ spec:
 ---
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.3
+  name: servicebgpstatuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceBGPStatus
+    listKind: ServiceBGPStatusList
+    plural: servicebgpstatuses
+    singular: servicebgpstatus
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Node
+      type: string
+    - jsonPath: .status.serviceName
+      name: Service Name
+      type: string
+    - jsonPath: .status.serviceNamespace
+      name: Service Namespace
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceBGPStatus exposes the BGP peers a service is configured
+          to be advertised to, per relevant node.
+        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: ServiceBGPStatusSpec defines the desired state of ServiceBGPStatus.
+            type: object
+          status:
+            description: MetalLBServiceBGPStatus defines the observed state of ServiceBGPStatus.
+            properties:
+              node:
+                description: Node indicates the node announcing the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              peers:
+                description: |-
+                  Peers indicate the BGP peers for which the service is configured to be advertised to.
+                  The service being actually advertised to a given peer depends on the session state and is not indicated here.
+                items:
+                  type: string
+                type: array
+              serviceName:
+                description: ServiceName indicates the service this status represents.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              serviceNamespace:
+                description: ServiceNamespace indicates the namespace of the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
 metadata:
   annotations:
     controller-gen.kubebuilder.io/version: v0.16.3
@@ -2019,6 +2105,13 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - servicebgpstatuses
+  - servicebgpstatuses/status
+  verbs:
+  - '*'
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRole
diff --git a/config/manifests/metallb-frr-prometheus.yaml b/config/manifests/metallb-frr-prometheus.yaml
index 36821896..f8f01361 100644
--- a/config/manifests/metallb-frr-prometheus.yaml
+++ b/config/manifests/metallb-frr-prometheus.yaml
@@ -1177,6 +1177,92 @@ spec:
 ---
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.3
+  name: servicebgpstatuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceBGPStatus
+    listKind: ServiceBGPStatusList
+    plural: servicebgpstatuses
+    singular: servicebgpstatus
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Node
+      type: string
+    - jsonPath: .status.serviceName
+      name: Service Name
+      type: string
+    - jsonPath: .status.serviceNamespace
+      name: Service Namespace
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceBGPStatus exposes the BGP peers a service is configured
+          to be advertised to, per relevant node.
+        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: ServiceBGPStatusSpec defines the desired state of ServiceBGPStatus.
+            type: object
+          status:
+            description: MetalLBServiceBGPStatus defines the observed state of ServiceBGPStatus.
+            properties:
+              node:
+                description: Node indicates the node announcing the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              peers:
+                description: |-
+                  Peers indicate the BGP peers for which the service is configured to be advertised to.
+                  The service being actually advertised to a given peer depends on the session state and is not indicated here.
+                items:
+                  type: string
+                type: array
+              serviceName:
+                description: ServiceName indicates the service this status represents.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              serviceNamespace:
+                description: ServiceNamespace indicates the namespace of the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
 metadata:
   annotations:
     controller-gen.kubebuilder.io/version: v0.16.3
@@ -1444,6 +1530,13 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - servicebgpstatuses
+  - servicebgpstatuses/status
+  verbs:
+  - '*'
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: Role
diff --git a/config/manifests/metallb-frr.yaml b/config/manifests/metallb-frr.yaml
index 4025e427..d3c77b55 100644
--- a/config/manifests/metallb-frr.yaml
+++ b/config/manifests/metallb-frr.yaml
@@ -1177,6 +1177,92 @@ spec:
 ---
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.3
+  name: servicebgpstatuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceBGPStatus
+    listKind: ServiceBGPStatusList
+    plural: servicebgpstatuses
+    singular: servicebgpstatus
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Node
+      type: string
+    - jsonPath: .status.serviceName
+      name: Service Name
+      type: string
+    - jsonPath: .status.serviceNamespace
+      name: Service Namespace
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceBGPStatus exposes the BGP peers a service is configured
+          to be advertised to, per relevant node.
+        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: ServiceBGPStatusSpec defines the desired state of ServiceBGPStatus.
+            type: object
+          status:
+            description: MetalLBServiceBGPStatus defines the observed state of ServiceBGPStatus.
+            properties:
+              node:
+                description: Node indicates the node announcing the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              peers:
+                description: |-
+                  Peers indicate the BGP peers for which the service is configured to be advertised to.
+                  The service being actually advertised to a given peer depends on the session state and is not indicated here.
+                items:
+                  type: string
+                type: array
+              serviceName:
+                description: ServiceName indicates the service this status represents.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              serviceNamespace:
+                description: ServiceNamespace indicates the namespace of the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
 metadata:
   annotations:
     controller-gen.kubebuilder.io/version: v0.16.3
@@ -1444,6 +1530,13 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - servicebgpstatuses
+  - servicebgpstatuses/status
+  verbs:
+  - '*'
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRole
diff --git a/config/manifests/metallb-native-prometheus.yaml b/config/manifests/metallb-native-prometheus.yaml
index 164a6b93..e70fdf0a 100644
--- a/config/manifests/metallb-native-prometheus.yaml
+++ b/config/manifests/metallb-native-prometheus.yaml
@@ -1177,6 +1177,92 @@ spec:
 ---
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.3
+  name: servicebgpstatuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceBGPStatus
+    listKind: ServiceBGPStatusList
+    plural: servicebgpstatuses
+    singular: servicebgpstatus
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Node
+      type: string
+    - jsonPath: .status.serviceName
+      name: Service Name
+      type: string
+    - jsonPath: .status.serviceNamespace
+      name: Service Namespace
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceBGPStatus exposes the BGP peers a service is configured
+          to be advertised to, per relevant node.
+        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: ServiceBGPStatusSpec defines the desired state of ServiceBGPStatus.
+            type: object
+          status:
+            description: MetalLBServiceBGPStatus defines the observed state of ServiceBGPStatus.
+            properties:
+              node:
+                description: Node indicates the node announcing the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              peers:
+                description: |-
+                  Peers indicate the BGP peers for which the service is configured to be advertised to.
+                  The service being actually advertised to a given peer depends on the session state and is not indicated here.
+                items:
+                  type: string
+                type: array
+              serviceName:
+                description: ServiceName indicates the service this status represents.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              serviceNamespace:
+                description: ServiceNamespace indicates the namespace of the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
 metadata:
   annotations:
     controller-gen.kubebuilder.io/version: v0.16.3
@@ -1444,6 +1530,13 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - servicebgpstatuses
+  - servicebgpstatuses/status
+  verbs:
+  - '*'
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: Role
diff --git a/config/manifests/metallb-native.yaml b/config/manifests/metallb-native.yaml
index a6f5018b..6a3fd3d4 100644
--- a/config/manifests/metallb-native.yaml
+++ b/config/manifests/metallb-native.yaml
@@ -1177,6 +1177,92 @@ spec:
 ---
 apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.3
+  name: servicebgpstatuses.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: ServiceBGPStatus
+    listKind: ServiceBGPStatusList
+    plural: servicebgpstatuses
+    singular: servicebgpstatus
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.node
+      name: Node
+      type: string
+    - jsonPath: .status.serviceName
+      name: Service Name
+      type: string
+    - jsonPath: .status.serviceNamespace
+      name: Service Namespace
+      type: string
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: ServiceBGPStatus exposes the BGP peers a service is configured
+          to be advertised to, per relevant node.
+        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: ServiceBGPStatusSpec defines the desired state of ServiceBGPStatus.
+            type: object
+          status:
+            description: MetalLBServiceBGPStatus defines the observed state of ServiceBGPStatus.
+            properties:
+              node:
+                description: Node indicates the node announcing the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              peers:
+                description: |-
+                  Peers indicate the BGP peers for which the service is configured to be advertised to.
+                  The service being actually advertised to a given peer depends on the session state and is not indicated here.
+                items:
+                  type: string
+                type: array
+              serviceName:
+                description: ServiceName indicates the service this status represents.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+              serviceNamespace:
+                description: ServiceNamespace indicates the namespace of the service.
+                type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
 metadata:
   annotations:
     controller-gen.kubebuilder.io/version: v0.16.3
@@ -1444,6 +1530,13 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - servicebgpstatuses
+  - servicebgpstatuses/status
+  verbs:
+  - '*'
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRole
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index b607a4b7..1fc501d1 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -215,6 +215,13 @@ rules:
       - get
       - list
       - watch
+  - apiGroups:
+      - metallb.io
+    resources:
+      - "servicebgpstatuses"
+      - "servicebgpstatuses/status"
+    verbs:
+      - "*"
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: Role
diff --git a/internal/k8s/controllers/bgp_status_controller.go b/internal/k8s/controllers/bgp_status_controller.go
new file mode 100644
index 00000000..14594952
--- /dev/null
+++ b/internal/k8s/controllers/bgp_status_controller.go
@@ -0,0 +1,239 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package controllers
+
+import (
+	"context"
+	"fmt"
+	"reflect"
+
+	"github.com/go-kit/log"
+	"github.com/go-kit/log/level"
+	"go.universe.tf/metallb/api/v1beta1"
+	v1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+	"k8s.io/apimachinery/pkg/util/sets"
+	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/event"
+	"sigs.k8s.io/controller-runtime/pkg/handler"
+	"sigs.k8s.io/controller-runtime/pkg/predicate"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+	"sigs.k8s.io/controller-runtime/pkg/source"
+)
+
+type PeersForService func(key string) sets.Set[string]
+
+type bgpStatusEvent struct {
+	metav1.TypeMeta
+	metav1.ObjectMeta
+}
+
+func (evt *bgpStatusEvent) DeepCopyObject() runtime.Object {
+	res := new(bgpStatusEvent)
+	res.Name = evt.Name
+	res.Namespace = evt.Namespace
+	return res
+}
+
+func NewBGPStatusEvent(namespace, name string) event.GenericEvent {
+	evt := bgpStatusEvent{}
+	evt.Name = name
+	evt.Namespace = namespace
+	return event.GenericEvent{Object: &evt}
+}
+
+type ServiceBGPStatusReconciler struct {
+	client.Client
+	Logger        log.Logger
+	NodeName      string
+	Namespace     string
+	SpeakerPod    *v1.Pod
+	ReconcileChan <-chan event.GenericEvent
+	PeersFetcher  PeersForService
+}
+
+func (r *ServiceBGPStatusReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	level.Info(r.Logger).Log("controller", "ServiceBGPStatus", "start reconcile", req.NamespacedName.String())
+	defer level.Info(r.Logger).Log("controller", "ServiceBGPStatus", "end reconcile", req.NamespacedName.String())
+
+	serviceName, serviceNamespace := req.Name, req.Namespace
+
+	var serviceBGPStatuses v1beta1.ServiceBGPStatusList
+	err := r.Client.List(ctx, &serviceBGPStatuses, client.MatchingFields{
+		serviceIndexName: indexFor(serviceNamespace, serviceName, r.NodeName),
+	})
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+
+	peers := r.PeersFetcher(req.NamespacedName.String())
+	if peers.Len() == 0 {
+		errs := []error{}
+		for i := range serviceBGPStatuses.Items {
+			if serviceBGPStatuses.Items[i].Labels[LabelAnnounceNode] != r.NodeName { // shouldn't happen because of the indexing, just in case
+				continue
+			}
+			if err := r.Client.Delete(ctx, &serviceBGPStatuses.Items[i]); err != nil && !apierrors.IsNotFound(err) {
+				errs = append(errs, err)
+			}
+		}
+
+		return ctrl.Result{}, utilerrors.NewAggregate(errs)
+	}
+
+	deleteRedundantErrs := []error{}
+	if len(serviceBGPStatuses.Items) > 1 {
+		// We shouldn't get here, just in case the controller created redundant resources
+		for i := range serviceBGPStatuses.Items[1:] {
+			if serviceBGPStatuses.Items[i+1].Labels[LabelAnnounceNode] != r.NodeName {
+				continue
+			}
+			if err := r.Client.Delete(ctx, &serviceBGPStatuses.Items[i+1]); err != nil && !apierrors.IsNotFound(err) {
+				deleteRedundantErrs = append(deleteRedundantErrs, err)
+			}
+		}
+	}
+
+	if len(deleteRedundantErrs) > 0 {
+		return ctrl.Result{}, utilerrors.NewAggregate(deleteRedundantErrs)
+	}
+
+	var state = &v1beta1.ServiceBGPStatus{
+		ObjectMeta: metav1.ObjectMeta{
+			GenerateName: "bgp-",
+			Namespace:    r.Namespace,
+		},
+	}
+
+	if len(serviceBGPStatuses.Items) > 0 {
+		state = &serviceBGPStatuses.Items[0]
+	}
+
+	desiredStatus := v1beta1.MetalLBServiceBGPStatus{
+		Node:             r.NodeName,
+		ServiceName:      serviceName,
+		ServiceNamespace: serviceNamespace,
+		Peers:            sets.List(peers),
+	}
+
+	if reflect.DeepEqual(state.Status, desiredStatus) {
+		return ctrl.Result{}, nil
+	}
+
+	var result controllerutil.OperationResult
+	result, err = controllerutil.CreateOrPatch(ctx, r.Client, state, func() error {
+		state.Labels = map[string]string{
+			LabelAnnounceNode:     r.NodeName,
+			LabelServiceName:      serviceName,
+			LabelServiceNamespace: serviceNamespace,
+		}
+		state.Status = desiredStatus
+		err = controllerutil.SetOwnerReference(r.SpeakerPod, state, r.Scheme())
+		if err != nil {
+			return err
+		}
+		return nil
+	})
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+	if result == controllerutil.OperationResultCreated {
+		// According to https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/controller/controllerutil#CreateOrPatch
+		// If the object is created, we have to patch it again to ensure the status is created.
+		// This will happen when we reconcile the creation event.
+		level.Debug(r.Logger).Log("controller", "ServiceBGPStatus", "created state", dumpResource(state))
+		return ctrl.Result{}, nil
+	}
+
+	level.Debug(r.Logger).Log("controller", "ServiceBGPStatus", "updated state", dumpResource(state))
+	return ctrl.Result{}, nil
+}
+
+func (r *ServiceBGPStatusReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	p := predicate.NewPredicateFuncs(func(o client.Object) bool {
+		_, ok := o.(*v1beta1.ServiceBGPStatus)
+		if !ok {
+			return true
+		}
+
+		labels := o.GetLabels()
+
+		if labels == nil {
+			level.Error(r.Logger).Log("controller", "ServiceBGPStatus", "object has no labels", o)
+			return false
+		}
+
+		if _, ok = labels[LabelServiceName]; !ok {
+			level.Error(r.Logger).Log("controller", "ServiceBGPStatus", "object does not have the servicename label", o)
+			return false
+		}
+
+		if _, ok = labels[LabelServiceNamespace]; !ok {
+			level.Error(r.Logger).Log("controller", "ServiceBGPStatus", "object does not have the servicenamespace label", o)
+			return false
+		}
+
+		var node string
+		if node, ok = labels[LabelAnnounceNode]; !ok {
+			level.Error(r.Logger).Log("controller", "ServiceBGPStatus", "object does not have the node name label", o)
+			return false
+		}
+
+		if node != r.NodeName {
+			return false
+		}
+
+		return true
+	})
+
+	err := mgr.GetFieldIndexer().IndexField(context.Background(), &v1beta1.ServiceBGPStatus{}, serviceIndexName,
+		func(o client.Object) []string {
+			s, ok := o.(*v1beta1.ServiceBGPStatus)
+			if s == nil {
+				level.Error(r.Logger).Log("controller", "fieldindexer", "error", "received nil ServiceBGPStatus")
+				return nil
+			}
+
+			if !ok {
+				level.Error(r.Logger).Log("controller", "fieldindexer", "error", "received object that is not ServiceBGPStatus", "object", o)
+				return nil
+			}
+
+			labels := s.GetLabels()
+			if labels == nil {
+				level.Error(r.Logger).Log("controller", "fieldindexer", "error", "received ServiceBGPStatus without labels", "object", o)
+				return nil
+			}
+
+			return []string{indexFor(labels[LabelServiceNamespace], labels[LabelServiceName], labels[LabelAnnounceNode])}
+		})
+
+	if err != nil {
+		return err
+	}
+
+	return ctrl.NewControllerManagedBy(mgr).
+		Named("servicebgpstatus").
+		Watches(&v1beta1.ServiceBGPStatus{}, handler.EnqueueRequestsFromMapFunc(
+			func(ctx context.Context, object client.Object) []reconcile.Request {
+				level.Debug(r.Logger).Log("controller", "ServiceBGPStatus", "enqueueing", "object", object)
+				labels := object.GetLabels()
+				return []reconcile.Request{{NamespacedName: types.NamespacedName{
+					Name:      labels[LabelServiceName],
+					Namespace: labels[LabelServiceNamespace],
+				}}}
+			})).
+		WatchesRawSource(source.Channel(r.ReconcileChan, &handler.EnqueueRequestForObject{})).
+		WithEventFilter(p).
+		Complete(r)
+}
+
+func indexFor(svcNs, svcName, node string) string {
+	return fmt.Sprintf("%s/%s-%s", svcNs, svcName, node)
+}
diff --git a/internal/k8s/controllers/layer2_status_controller.go b/internal/k8s/controllers/layer2_status_controller.go
index eb4f5ea5..1ef426f2 100644
--- a/internal/k8s/controllers/layer2_status_controller.go
+++ b/internal/k8s/controllers/layer2_status_controller.go
@@ -28,7 +28,7 @@ import (
 	"go.universe.tf/metallb/internal/layer2"
 )
 
-type StatusFetcher func(types.NamespacedName) []layer2.IPAdvertisement
+type L2StatusFetcher func(types.NamespacedName) []layer2.IPAdvertisement
 
 type l2StatusEvent struct {
 	metav1.TypeMeta
@@ -61,7 +61,7 @@ type Layer2StatusReconciler struct {
 	SpeakerPod    *v1.Pod
 	ReconcileChan <-chan event.GenericEvent
 	// fetch ipAdv object to get interface info
-	StatusFetcher StatusFetcher
+	StatusFetcher L2StatusFetcher
 }
 
 func (r *Layer2StatusReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
diff --git a/internal/k8s/k8s.go b/internal/k8s/k8s.go
index 110197ec..4a1019f9 100644
--- a/internal/k8s/k8s.go
+++ b/internal/k8s/k8s.go
@@ -121,7 +121,9 @@ type Config struct {
 	FRRK8sNamespace     string
 	Listener
 	Layer2StatusChan    <-chan event.GenericEvent
-	Layer2StatusFetcher controllers.StatusFetcher
+	Layer2StatusFetcher controllers.L2StatusFetcher
+	BGPStatusChan       <-chan event.GenericEvent
+	BGPPeersFetcher     controllers.PeersForService
 }
 
 // New connects to masterAddr, using kubeconfig to authenticate.
@@ -141,6 +143,7 @@ func New(cfg *Config) (*Client, error) {
 		&metallbv1beta1.L2Advertisement{}:  namespaceSelector,
 		&metallbv1beta2.BGPPeer{}:          namespaceSelector,
 		&metallbv1beta1.Community{}:        namespaceSelector,
+		&metallbv1beta1.ServiceBGPStatus{}: namespaceSelector,
 		&corev1.Secret{}:                   namespaceSelector,
 		&corev1.ConfigMap{}:                namespaceSelector,
 	}
@@ -297,6 +300,25 @@ func New(cfg *Config) (*Client, error) {
 		}
 	}
 
+	if cfg.BGPStatusChan != nil {
+		selfPod, err := clientset.CoreV1().Pods(cfg.Namespace).Get(context.TODO(), cfg.PodName, metav1.GetOptions{})
+		if err != nil {
+			level.Error(c.logger).Log("unable to get speaker pod itself", err)
+			return nil, err
+		}
+		if err = (&controllers.ServiceBGPStatusReconciler{
+			Client:        mgr.GetClient(),
+			Logger:        cfg.Logger,
+			NodeName:      cfg.NodeName,
+			Namespace:     cfg.Namespace,
+			SpeakerPod:    selfPod.DeepCopy(),
+			ReconcileChan: cfg.BGPStatusChan,
+			PeersFetcher:  cfg.BGPPeersFetcher,
+		}).SetupWithManager(mgr); err != nil {
+			level.Error(c.logger).Log("error", err, "unable to create controller", "layer2Status")
+		}
+	}
+
 	startListeners := make(chan struct{})
 	go func(l log.Logger) {
 		// We start the webhooks and the metric at the same time so the readiness probe will
diff --git a/speaker/bgp_controller.go b/speaker/bgp_controller.go
index 20cc733b..47061ef6 100644
--- a/speaker/bgp_controller.go
+++ b/speaker/bgp_controller.go
@@ -20,7 +20,10 @@ import (
 	"reflect"
 	"sort"
 	"strconv"
+	"sync"
 
+	"github.com/go-kit/log"
+	"github.com/go-kit/log/level"
 	"go.universe.tf/metallb/internal/bgp"
 	bgpfrr "go.universe.tf/metallb/internal/bgp/frr"
 	bgpfrrk8s "go.universe.tf/metallb/internal/bgp/frrk8s"
@@ -31,9 +34,7 @@ import (
 	v1 "k8s.io/api/core/v1"
 	discovery "k8s.io/api/discovery/v1"
 	"k8s.io/apimachinery/pkg/labels"
-
-	"github.com/go-kit/log"
-	"github.com/go-kit/log/level"
+	"k8s.io/apimachinery/pkg/util/sets"
 
 	"errors"
 )
@@ -61,15 +62,18 @@ type peer struct {
 }
 
 type bgpController struct {
-	logger          log.Logger
-	myNode          string
-	nodeLabels      labels.Set
-	peers           []*peer
-	svcAds          map[string][]*bgp.Advertisement
-	bgpType         bgpImplementation
-	secretHandling  SecretHandling
-	sessionManager  bgp.SessionManager
-	ignoreExcludeLB bool
+	logger             log.Logger
+	myNode             string
+	nodeLabels         labels.Set
+	peers              []*peer
+	svcAds             map[string][]*bgp.Advertisement
+	activeAds          map[string]sets.Set[string] // svc -> the peers it is advertised to
+	activeAdsMutex     sync.RWMutex
+	adsChangedCallback func(string)
+	bgpType            bgpImplementation
+	secretHandling     SecretHandling
+	sessionManager     bgp.SessionManager
+	ignoreExcludeLB    bool
 }
 
 func (c *bgpController) SetConfig(l log.Logger, cfg *config.Config) error {
@@ -348,6 +352,15 @@ func (c *bgpController) SetBalancer(l log.Logger, name string, lbIPs []net.IP, p
 }
 
 func (c *bgpController) updateAds() error {
+	newAds, err := c.publishAds()
+	if err != nil {
+		return err
+	}
+	c.notifyAdsChanged(newAds)
+	return nil
+}
+
+func (c *bgpController) publishAds() (map[string][]*bgp.Advertisement, error) {
 	var allAds []*bgp.Advertisement
 	for _, ads := range c.svcAds {
 		// This list might contain duplicates, but that's fine,
@@ -358,16 +371,75 @@ func (c *bgpController) updateAds() error {
 		// and detecting conflicting advertisements.
 		allAds = append(allAds, ads...)
 	}
+	adsSet := map[string][]*bgp.Advertisement{}
 	for _, peer := range c.peers {
 		if peer.session == nil {
 			continue
 		}
 		ads := adsForPeer(peer.cfg.Name, allAds)
 		if err := peer.session.Set(ads...); err != nil {
-			return err
+			return nil, err
 		}
+		adsSet[peer.cfg.Name] = ads
 	}
-	return nil
+	return adsSet, nil
+}
+
+func (c *bgpController) notifyAdsChanged(newAds map[string][]*bgp.Advertisement) {
+	changedSvcs := []string{} // the services that their advs changed
+	defer func() {
+		for _, k := range changedSvcs {
+			c.adsChangedCallback(k)
+		}
+	}()
+
+	c.activeAdsMutex.Lock()
+	defer c.activeAdsMutex.Unlock()
+
+	pfxToSvc := map[string]sets.Set[string]{} // prefix -> the services that use it
+	for svcKey, ads := range c.svcAds {
+		for _, ad := range ads {
+			if _, ok := pfxToSvc[ad.Prefix.String()]; !ok {
+				pfxToSvc[ad.Prefix.String()] = sets.New(svcKey)
+				continue
+			}
+			pfxToSvc[ad.Prefix.String()].Insert(svcKey)
+		}
+	}
+
+	oldActiveAds := c.activeAds
+	newActiveAds := map[string]sets.Set[string]{}
+	for peer, ads := range newAds {
+		for _, ad := range ads {
+			adSvcs := pfxToSvc[ad.Prefix.String()]
+			for svc := range adSvcs {
+				if _, ok := newActiveAds[svc]; !ok {
+					newActiveAds[svc] = sets.New(peer)
+					continue
+				}
+				newActiveAds[svc].Insert(peer)
+			}
+		}
+	}
+
+	for svc, oldPeers := range oldActiveAds {
+		newPeers, ok := newActiveAds[svc]
+		if !ok {
+			changedSvcs = append(changedSvcs, svc)
+			continue
+		}
+		if !oldPeers.Equal(newPeers) {
+			changedSvcs = append(changedSvcs, svc)
+		}
+	}
+	for svc := range newActiveAds {
+		_, ok := oldActiveAds[svc]
+		if !ok {
+			changedSvcs = append(changedSvcs, svc)
+			continue
+		}
+	}
+	c.activeAds = newActiveAds
 }
 
 func adsForPeer(peerName string, ads []*bgp.Advertisement) []*bgp.Advertisement {
@@ -431,3 +503,9 @@ func poolMatchesNodeBGP(pool *config.Pool, node string) bool {
 	}
 	return false
 }
+
+func (c *bgpController) PeersForService(key string) sets.Set[string] {
+	c.activeAdsMutex.RLock()
+	defer c.activeAdsMutex.RUnlock()
+	return c.activeAds[key]
+}
diff --git a/speaker/main.go b/speaker/main.go
index 1bf51cc8..ba82511c 100644
--- a/speaker/main.go
+++ b/speaker/main.go
@@ -31,6 +31,8 @@ import (
 	v1 "k8s.io/api/core/v1"
 	discovery "k8s.io/api/discovery/v1"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/client-go/tools/cache"
 	"sigs.k8s.io/controller-runtime/pkg/event"
 	"sigs.k8s.io/yaml"
 
@@ -152,11 +154,14 @@ func main() {
 		level.Error(logger).Log("op", "startup", "msg", "failed to parse announcedInterfacesToExclude from configMap", "error", err)
 		os.Exit(1)
 	}
-	statusNotifyChan := make(chan event.GenericEvent)
 
 	if *frrK8sNamespace == "" { // if not set, assuming it runs under metallb
 		frrK8sNamespace = namespace
 	}
+
+	l2StatusChan := make(chan event.GenericEvent)
+	bgpStatusChan := make(chan event.GenericEvent)
+
 	// Setup all clients and speakers, config decides what is being done runtime.
 	ctrl, err := newController(controllerConfig{
 		MyNode:                 *myNode,
@@ -169,7 +174,15 @@ func main() {
 		InterfaceExcludeRegexp: interfacesToExclude,
 		IgnoreExcludeLB:        *ignoreLBExclude,
 		Layer2StatusChange: func(namespacedName types.NamespacedName) {
-			statusNotifyChan <- controllers.NewL2StatusEvent(namespacedName.Namespace, namespacedName.Name)
+			l2StatusChan <- controllers.NewL2StatusEvent(namespacedName.Namespace, namespacedName.Name)
+		},
+		BGPAdsChangedCallback: func(key string) {
+			ns, name, err := cache.SplitMetaNamespaceKey(key)
+			if err != nil {
+				level.Debug(logger).Log("op", "bgpStatusEvent", "error", err, "msg", "failed to parse key as namespaced name", "key", key)
+				return
+			}
+			bgpStatusChan <- controllers.NewBGPStatusEvent(ns, name)
 		},
 	})
 	if err != nil {
@@ -210,8 +223,10 @@ func main() {
 		WithFRRK8s:        listenFRRK8s,
 		FRRK8sNamespace:   *frrK8sNamespace,
 
-		Layer2StatusChan:    statusNotifyChan,
+		Layer2StatusChan:    l2StatusChan,
 		Layer2StatusFetcher: ctrl.layer2StatusFetchFunc,
+		BGPStatusChan:       bgpStatusChan,
+		BGPPeersFetcher:     ctrl.bgpPeersFetcher,
 	})
 	if err != nil {
 		level.Error(logger).Log("op", "startup", "error", err, "msg", "failed to create k8s client")
@@ -243,7 +258,8 @@ type controller struct {
 
 	protocols []config.Proto
 
-	layer2StatusFetchFunc controllers.StatusFetcher
+	layer2StatusFetchFunc controllers.L2StatusFetcher
+	bgpPeersFetcher       controllers.PeersForService
 }
 
 type controllerConfig struct {
@@ -264,6 +280,7 @@ type controllerConfig struct {
 	InterfaceExcludeRegexp       *regexp.Regexp
 	IgnoreExcludeLB              bool
 	Layer2StatusChange           func(types.NamespacedName)
+	BGPAdsChangedCallback        func(string)
 }
 
 func newController(cfg controllerConfig) (*controller, error) {
@@ -274,16 +291,21 @@ func newController(cfg controllerConfig) (*controller, error) {
 		secretHandling = SecretConvert
 	}
 
+	bgpController := &bgpController{
+		logger:             cfg.Logger,
+		myNode:             cfg.MyNode,
+		svcAds:             make(map[string][]*bgp.Advertisement),
+		activeAds:          make(map[string]sets.Set[string]),
+		adsChangedCallback: cfg.BGPAdsChangedCallback,
+		bgpType:            cfg.bgpType,
+		sessionManager:     newBGP(cfg),
+		ignoreExcludeLB:    cfg.IgnoreExcludeLB,
+		secretHandling:     secretHandling,
+	}
+	bgpPeersFetcher := bgpController.PeersForService
+
 	handlers := map[config.Proto]Protocol{
-		config.BGP: &bgpController{
-			logger:          cfg.Logger,
-			myNode:          cfg.MyNode,
-			svcAds:          make(map[string][]*bgp.Advertisement),
-			bgpType:         cfg.bgpType,
-			sessionManager:  newBGP(cfg),
-			ignoreExcludeLB: cfg.IgnoreExcludeLB,
-			secretHandling:  secretHandling,
-		},
+		config.BGP: bgpController,
 	}
 	protocols := []config.Proto{config.BGP}
 
@@ -312,6 +334,7 @@ func newController(cfg controllerConfig) (*controller, error) {
 		svcIPs:                map[string][]net.IP{},
 		protocols:             protocols,
 		layer2StatusFetchFunc: layer2StatusFetcher,
+		bgpPeersFetcher:       bgpPeersFetcher,
 	}
 	ret.announced[config.BGP] = map[string]bool{}
 	ret.announced[config.Layer2] = map[string]bool{}
diff --git a/website/content/apis/_index.md b/website/content/apis/_index.md
index 74cc8a3e..6ea3adc4 100644
--- a/website/content/apis/_index.md
+++ b/website/content/apis/_index.md
@@ -244,6 +244,8 @@ _Appears in:_
 
 
 
+
+
 ## metallb.io/v1beta2
 
 


Test patch:
diff --git a/e2etest/bgptests/bgp.go b/e2etest/bgptests/bgp.go
index 62ecac62..02da5339 100644
--- a/e2etest/bgptests/bgp.go
+++ b/e2etest/bgptests/bgp.go
@@ -26,6 +26,8 @@ import (
 	"strings"
 	"time"
 
+	"github.com/google/go-cmp/cmp"
+
 	frrk8sv1beta1 "github.com/metallb/frr-k8s/api/v1beta1"
 	"github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -38,6 +40,7 @@ import (
 	"go.universe.tf/e2etest/pkg/k8sclient"
 	"go.universe.tf/e2etest/pkg/mac"
 	"go.universe.tf/e2etest/pkg/metallb"
+	"go.universe.tf/e2etest/pkg/status"
 	metallbv1beta1 "go.universe.tf/metallb/api/v1beta1"
 	metallbv1beta2 "go.universe.tf/metallb/api/v1beta2"
 
@@ -48,8 +51,10 @@ import (
 	"go.universe.tf/e2etest/pkg/ipfamily"
 	testservice "go.universe.tf/e2etest/pkg/service"
 	corev1 "k8s.io/api/core/v1"
+	k8serrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/sets"
 	clientset "k8s.io/client-go/kubernetes"
 	"k8s.io/utils/ptr"
 )
@@ -600,6 +605,142 @@ var _ = ginkgo.Describe("BGP", func() {
 		ginkgo.Entry("IPV4 with Secret Ref set for BGPPeer CR", ipfamily.IPv4),
 		ginkgo.Entry("IPV6 with Secret Ref set for BGPPeer CR", ipfamily.IPv6))
 
+	ginkgo.DescribeTable("ServiceBGPStatus", func(ipFamily ipfamily.Family, poolAddresses []string, tweak testservice.Tweak) {
+		validateStatusesFor := func(nodes []string, peers sets.Set[string], svc *corev1.Service, expectNoResources bool) error {
+			for _, n := range nodes {
+				s, err := status.BGPForServiceAndNode(ConfigUpdater.Client(), svc, n)
+				if expectNoResources && !k8serrors.IsNotFound(err) {
+					return fmt.Errorf("expected status for node %s to not be there, got %v with err %w", n, s, err)
+				}
+				if expectNoResources && k8serrors.IsNotFound(err) {
+					continue
+				}
+				if err != nil {
+					return err
+				}
+				statusPeers := sets.New(s.Status.Peers...)
+				if !peers.Equal(statusPeers) {
+					return fmt.Errorf("expected status peers to be %v, got %v for node %s\n diff: %s", peers, s.Status.Peers, n, cmp.Diff(sets.List(peers), s.Status.Peers))
+				}
+			}
+			return nil
+		}
+
+		peers := metallb.PeersForContainers(FRRContainers, ipFamily)
+		peersNames := sets.Set[string]{}
+		for _, p := range peers {
+			peersNames.Insert(p.Name)
+		}
+
+		bgpAdv := metallbv1beta1.BGPAdvertisement{
+			ObjectMeta: metav1.ObjectMeta{Name: "empty", Namespace: ConfigUpdater.Namespace()},
+			Spec:       metallbv1beta1.BGPAdvertisementSpec{},
+		}
+
+		ginkgo.By("Creating the service advertised to all peers")
+		resources := config.Resources{
+			Pools: []metallbv1beta1.IPAddressPool{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "bgp-test",
+					},
+					Spec: metallbv1beta1.IPAddressPoolSpec{
+						Addresses: poolAddresses,
+					},
+				},
+			},
+			Peers:   peers,
+			BGPAdvs: []metallbv1beta1.BGPAdvertisement{bgpAdv},
+		}
+
+		err := ConfigUpdater.Update(resources)
+		Expect(err).NotTo(HaveOccurred())
+
+		svc, _ := testservice.CreateWithBackend(cs, testNamespace, "external-local-lb", func(svc *corev1.Service) {
+			testservice.TrafficPolicyCluster(svc)
+			tweak(svc)
+		})
+		svcDeleted := false
+		defer func() {
+			if !svcDeleted {
+				testservice.Delete(cs, svc)
+			}
+		}()
+
+		for _, i := range svc.Status.LoadBalancer.Ingress {
+			ginkgo.By("validate LoadBalancer IP is in the AddressPool range")
+			ingressIP := jigservice.GetIngressPoint(&i)
+			err = config.ValidateIPInRange(resources.Pools, ingressIP)
+			Expect(err).NotTo(HaveOccurred())
+		}
+
+		nodes, err := cs.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
+		Expect(err).ToNot(HaveOccurred())
+		nodesNames := []string{}
+		for _, n := range nodes.Items {
+			nodesNames = append(nodesNames, n.Name)
+		}
+
+		ginkgo.By("Verifying all nodes create a status for the service")
+		Eventually(func() error {
+			return validateStatusesFor(nodesNames, peersNames, svc, false)
+		}, 2*time.Minute, 2*time.Second).ShouldNot(HaveOccurred())
+
+		ginkgo.By("Adding a dummy peer to the adv")
+		err = ConfigUpdater.Client().Get(context.TODO(), types.NamespacedName{Namespace: bgpAdv.Namespace, Name: bgpAdv.Name}, &bgpAdv)
+		Expect(err).ToNot(HaveOccurred())
+		bgpAdv.Spec.Peers = append(sets.List(peersNames), "dummy")
+		err = ConfigUpdater.Client().Update(context.TODO(), &bgpAdv)
+		Expect(err).ToNot(HaveOccurred())
+
+		Consistently(func() error {
+			return validateStatusesFor(nodesNames, peersNames, svc, false)
+		}, 5*time.Second, 1*time.Second).ShouldNot(HaveOccurred(), "expected status peers to be the same as before after adding a dummy peer")
+
+		ginkgo.By("Removing the first peer")
+		peer0 := peers[0]
+		peer0.Namespace = ConfigUpdater.Namespace()
+		err = ConfigUpdater.Client().Delete(context.TODO(), &peer0)
+		Expect(err).ToNot(HaveOccurred())
+		peersNames.Delete(peer0.Name)
+		Eventually(func() error {
+			return validateStatusesFor(nodesNames, peersNames, svc, peersNames.Len() == 0)
+		}, 2*time.Minute, 2*time.Second).ShouldNot(HaveOccurred())
+
+		ginkgo.By("Updating the node selector of the adv to not include the first node")
+		err = ConfigUpdater.Client().Get(context.TODO(), types.NamespacedName{Namespace: bgpAdv.Namespace, Name: bgpAdv.Name}, &bgpAdv)
+		Expect(err).ToNot(HaveOccurred())
+		bgpAdv.Spec.NodeSelectors = []metav1.LabelSelector{
+			{
+				MatchExpressions: []metav1.LabelSelectorRequirement{
+					{
+						Operator: "In",
+						Key:      "kubernetes.io/hostname",
+						Values:   nodesNames[1:],
+					},
+				}},
+		}
+		err = ConfigUpdater.Client().Update(context.TODO(), &bgpAdv)
+		Expect(err).ToNot(HaveOccurred())
+
+		Eventually(func() error {
+			return validateStatusesFor([]string{nodesNames[0]}, sets.Set[string]{}, svc, true)
+		}, 2*time.Minute, 2*time.Second).ShouldNot(HaveOccurred())
+		Eventually(func() error {
+			return validateStatusesFor(nodesNames[1:], peersNames, svc, peersNames.Len() == 0)
+		}, 2*time.Minute, 2*time.Second).ShouldNot(HaveOccurred())
+
+		ginkgo.By("Validating the the statuses are deleted after deleting the service")
+		testservice.Delete(cs, svc)
+		svcDeleted = true
+		Eventually(func() error {
+			return validateStatusesFor(nodesNames, sets.Set[string]{}, svc, true)
+		}, 2*time.Minute, 2*time.Second).ShouldNot(HaveOccurred())
+	},
+		ginkgo.Entry("IPV4", ipfamily.IPv4, []string{v4PoolAddresses}, func(_ *corev1.Service) {}),
+		ginkgo.Entry("IPV6", ipfamily.IPv6, []string{v6PoolAddresses}, func(_ *corev1.Service) {}),
+		ginkgo.Entry("DUALSTACK", ipfamily.DualStack, []string{v4PoolAddresses, v6PoolAddresses}, testservice.DualStack))
+
 	ginkgo.Context("BFD", func() {
 		ginkgo.DescribeTable("should work with the given bfd profile", func(bfd metallbv1beta1.BFDProfile, pairingFamily ipfamily.Family, poolAddresses []string, tweak testservice.Tweak) {
 			resources := config.Resources{
diff --git a/e2etest/go.mod b/e2etest/go.mod
index 7ec81ce5..1cb0fa1f 100644
--- a/e2etest/go.mod
+++ b/e2etest/go.mod
@@ -15,7 +15,7 @@ require (
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/prometheus/client_golang v1.19.1 // indirect
 	go.uber.org/zap v1.27.0 // indirect
-	golang.org/x/sys v0.25.0 // indirect
+	golang.org/x/sys v0.28.0 // indirect
 	k8s.io/api v0.31.4
 	k8s.io/apiextensions-apiserver v0.31.1 // indirect
 	k8s.io/apimachinery v0.31.4
@@ -32,7 +32,7 @@ require (
 	github.com/prometheus/client_model v0.6.1
 	github.com/prometheus/common v0.55.0
 	go.universe.tf/metallb v0.13.7
-	golang.org/x/sync v0.7.0
+	golang.org/x/sync v0.10.0
 	k8s.io/kubectl v0.31.1
 )
 
@@ -92,10 +92,10 @@ require (
 	go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
-	golang.org/x/net v0.26.0 // indirect
+	golang.org/x/net v0.33.0 // indirect
 	golang.org/x/oauth2 v0.21.0 // indirect
-	golang.org/x/term v0.21.0 // indirect
-	golang.org/x/text v0.16.0 // indirect
+	golang.org/x/term v0.27.0 // indirect
+	golang.org/x/text v0.21.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
 	golang.org/x/tools v0.22.0 // indirect
 	gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
diff --git a/e2etest/go.sum b/e2etest/go.sum
index 2d23d680..cb19a4da 100644
--- a/e2etest/go.sum
+++ b/e2etest/go.sum
@@ -215,8 +215,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
-golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
+golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
 golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
@@ -225,8 +225,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
-golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -234,15 +234,15 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
-golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
-golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
+golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
+golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
diff --git a/e2etest/pkg/k8s/reporter.go b/e2etest/pkg/k8s/reporter.go
index cc04dcde..38861ca6 100644
--- a/e2etest/pkg/k8s/reporter.go
+++ b/e2etest/pkg/k8s/reporter.go
@@ -63,6 +63,7 @@ func InitReporter(kubeconfig, path string, namespaces ...string) *k8sreporter.Ku
 		{Cr: &corev1.ServiceList{}},
 		{Cr: &frrk8sv1beta1.FRRConfigurationList{}},
 		{Cr: &frrk8sv1beta1.FRRNodeStateList{}},
+		{Cr: &metallbv1beta1.ServiceBGPStatusList{}},
 	}
 
 	reporter, err := k8sreporter.New(kubeconfig, addToScheme, dumpNamespace, path, crds...)
diff --git a/e2etest/pkg/status/bgpstatus.go b/e2etest/pkg/status/bgpstatus.go
new file mode 100644
index 00000000..dc4f3d8e
--- /dev/null
+++ b/e2etest/pkg/status/bgpstatus.go
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package status
+
+import (
+	"context"
+	"fmt"
+
+	"go.universe.tf/e2etest/pkg/metallb"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+
+	"go.universe.tf/metallb/api/v1beta1"
+	v1 "k8s.io/api/core/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// Returns the ServiceBGPStatus resource for the given service and node.
+func BGPForServiceAndNode(cs client.Client, svc *v1.Service, node string) (*v1beta1.ServiceBGPStatus, error) {
+	statusList := v1beta1.ServiceBGPStatusList{}
+	err := cs.List(context.TODO(), &statusList,
+		client.InNamespace(metallb.Namespace),
+		client.MatchingLabels{
+			LabelServiceName:      svc.Name,
+			LabelServiceNamespace: svc.Namespace,
+			LabelAnnounceNode:     node,
+		},
+	)
+	svcKey := fmt.Sprintf("%s/%s", svc.Name, svc.Namespace)
+	if err != nil {
+		return nil, fmt.Errorf("could not get status for service %s on node %s, err: %w", svcKey, node, err)
+	}
+
+	if len(statusList.Items) == 0 {
+		return nil, errors.NewNotFound(schema.ParseGroupResource("ServiceBGPStatus.metallb.io"), svcKey)
+	}
+	if len(statusList.Items) > 1 {
+		return nil, fmt.Errorf("got more than 1 ServiceBGPStatus object for service %s node %s: %v", svcKey, node, statusList.Items)
+	}
+
+	return &statusList.Items[0], nil
+}
diff --git a/e2etest/pkg/status/l2status.go b/e2etest/pkg/status/l2status.go
index 01ff2e61..81ca9ce5 100644
--- a/e2etest/pkg/status/l2status.go
+++ b/e2etest/pkg/status/l2status.go
@@ -5,6 +5,7 @@ package status
 import (
 	"context"
 	"fmt"
+
 	"go.universe.tf/e2etest/pkg/metallb"
 	"k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/runtime/schema"
@@ -14,11 +15,6 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
-const (
-	LabelServiceName      = "metallb.io/service-name"
-	LabelServiceNamespace = "metallb.io/service-namespace"
-)
-
 func L2ForService(cs client.Client, svc *v1.Service) (*v1beta1.ServiceL2Status, error) {
 	statusList := v1beta1.ServiceL2StatusList{}
 	err := cs.List(context.TODO(), &statusList,
diff --git a/e2etest/pkg/status/labels.go b/e2etest/pkg/status/labels.go
new file mode 100644
index 00000000..bc75a999
--- /dev/null
+++ b/e2etest/pkg/status/labels.go
@@ -0,0 +1,9 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package status
+
+const (
+	LabelAnnounceNode     = "metallb.io/node"
+	LabelServiceName      = "metallb.io/service-name"
+	LabelServiceNamespace = "metallb.io/service-namespace"
+)
diff --git a/internal/k8s/controllers/reconciliation_test.go b/internal/k8s/controllers/reconciliation_test.go
index 62cd0657..de717b04 100644
--- a/internal/k8s/controllers/reconciliation_test.go
+++ b/internal/k8s/controllers/reconciliation_test.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"net"
 	"path/filepath"
+	"reflect"
 	"sync"
 	"sync/atomic"
 	"testing"
@@ -69,6 +70,10 @@ var (
 		layer2ServiceAdvs = advs
 	}
 	speakerPod *corev1.Pod
+
+	bgpStatusReconcileChan = make(chan event.GenericEvent)
+	bgpAdvs                = map[string]sets.Set[string]{}
+	bgpAdvsMutex           = sync.Mutex{}
 )
 
 const (
@@ -215,6 +220,22 @@ var _ = BeforeSuite(func() {
 	err = layer2StatusReconciler.SetupWithManager(k8sManager)
 	Expect(err).ToNot(HaveOccurred())
 
+	bgpStatusReconciler := &ServiceBGPStatusReconciler{
+		Client:        k8sManager.GetClient(),
+		Logger:        log.NewNopLogger(),
+		NodeName:      testNodeName,
+		Namespace:     speakerNamespace,
+		SpeakerPod:    speakerPod,
+		ReconcileChan: bgpStatusReconcileChan,
+		PeersFetcher: func(key string) sets.Set[string] {
+			bgpAdvsMutex.Lock()
+			defer bgpAdvsMutex.Unlock()
+			return bgpAdvs[key]
+		},
+	}
+	err = bgpStatusReconciler.SetupWithManager(k8sManager)
+	Expect(err).ToNot(HaveOccurred())
+
 	go func() {
 		defer func() { mgrDone.Store(true) }()
 		defer GinkgoRecover()
@@ -569,3 +590,145 @@ var _ = Describe("Layer2 Status Controller", func() {
 		})
 	})
 })
+
+var _ = Describe("BGP Status Controller", func() {
+	Context("SetupWithManager", func() {
+		It("Should Reconcile correctly", func() {
+			serviceKey := types.NamespacedName{Namespace: testNamespace, Name: testServiceName}.String()
+			getStatus := func() (*v1beta1.ServiceBGPStatus, error) {
+				list := v1beta1.ServiceBGPStatusList{}
+				err := k8sClient.List(context.TODO(), &list)
+				if err != nil {
+					return nil, err
+				}
+
+				if len(list.Items) != 1 {
+					return nil, fmt.Errorf("expected 1 status, got %v", list.Items)
+				}
+
+				status := list.Items[0]
+				if len(status.OwnerReferences) != 1 {
+					return nil, fmt.Errorf("expected 1 owner reference, got %v", status.OwnerReferences)
+				}
+
+				ownerRef := status.OwnerReferences[0]
+				if ownerRef.UID != speakerPod.UID {
+					return nil, fmt.Errorf("owner reference is not speaker pod, got %v", ownerRef)
+				}
+
+				if status.Labels[LabelAnnounceNode] != testNodeName {
+					return nil, fmt.Errorf("labels do not match node, got %v", status.Labels)
+				}
+
+				if status.Status.Node != testNodeName {
+					return nil, fmt.Errorf("status does not match node, got %v", status.Status)
+				}
+
+				if status.Labels[LabelServiceNamespace] != testNamespace {
+					return nil, fmt.Errorf("labels do not match namespace, got %v", status.Labels)
+				}
+
+				if status.Status.ServiceNamespace != testNamespace {
+					return nil, fmt.Errorf("status does not match namespace, got %v", status.Status)
+				}
+
+				if status.Labels[LabelServiceName] != testServiceName {
+					return nil, fmt.Errorf("labels do not match service name, got %v", status.Labels)
+				}
+
+				if status.Status.ServiceName != testServiceName {
+					return nil, fmt.Errorf("status does not match service name, got %v", status.Status)
+				}
+
+				return &status, nil
+			}
+
+			bgpAdvsMutex.Lock()
+			bgpAdvs[serviceKey] = sets.New[string]("peer1")
+			bgpAdvsMutex.Unlock()
+			bgpStatusReconcileChan <- NewBGPStatusEvent(testNamespace, testServiceName)
+			expectedPeers := []string{"peer1"}
+			Eventually(func() error {
+				s, err := getStatus()
+				if err != nil {
+					return err
+				}
+
+				if !reflect.DeepEqual(expectedPeers, s.Status.Peers) {
+					return fmt.Errorf("expected peers to be %v, got %v", expectedPeers, s.Status.Peers)
+				}
+
+				return nil
+			}, 5*time.Second, 200*time.Millisecond).ShouldNot(HaveOccurred())
+
+			bgpAdvsMutex.Lock()
+			bgpAdvs[serviceKey] = sets.New[string]("peer1", "peer2")
+			bgpAdvsMutex.Unlock()
+			bgpStatusReconcileChan <- NewBGPStatusEvent(testNamespace, testServiceName)
+			expectedPeers = []string{"peer1", "peer2"}
+			Eventually(func() error {
+				s, err := getStatus()
+				if err != nil {
+					return err
+				}
+
+				if !reflect.DeepEqual(expectedPeers, s.Status.Peers) {
+					return fmt.Errorf("expected peers to be %v, got %v", expectedPeers, s.Status.Peers)
+				}
+
+				return nil
+			}, 5*time.Second, 200*time.Millisecond).ShouldNot(HaveOccurred())
+
+			// Manual updates should be reverted by the controller
+			status, err := getStatus()
+			Expect(err).ToNot(HaveOccurred())
+			status.Status.Peers = []string{"manual"}
+			err = k8sClient.Status().Update(context.TODO(), status)
+			Expect(err).To(Not(HaveOccurred()))
+			Eventually(func() error {
+				s, err := getStatus()
+				if err != nil {
+					return err
+				}
+
+				if !reflect.DeepEqual(expectedPeers, s.Status.Peers) {
+					return fmt.Errorf("expected peers to be %v, got %v", expectedPeers, s.Status.Peers)
+				}
+
+				return nil
+			}, 5*time.Second, 200*time.Millisecond).ShouldNot(HaveOccurred())
+
+			bgpAdvsMutex.Lock()
+			delete(bgpAdvs, serviceKey)
+			bgpAdvsMutex.Unlock()
+			bgpStatusReconcileChan <- NewBGPStatusEvent(testNamespace, testServiceName)
+			Eventually(func() error {
+				list := v1beta1.ServiceBGPStatusList{}
+				err := k8sClient.List(context.TODO(), &list)
+				if err != nil {
+					return err
+				}
+
+				if len(list.Items) != 0 {
+					return fmt.Errorf("expected no statuses, got %v", list.Items)
+				}
+
+				return nil
+			}, 5*time.Second, 200*time.Millisecond).ShouldNot(HaveOccurred())
+
+			Consistently(func() error {
+				list := v1beta1.ServiceBGPStatusList{}
+				err := k8sClient.List(context.TODO(), &list)
+				if err != nil {
+					return err
+				}
+
+				if len(list.Items) != 0 {
+					return fmt.Errorf("expected no statuses, got %v", list.Items)
+				}
+
+				return nil
+			}, 1*time.Second, 200*time.Millisecond).ShouldNot(HaveOccurred())
+		})
+	})
+})
diff --git a/speaker/bgp_controller_test.go b/speaker/bgp_controller_test.go
index e9a91ad9..1490737d 100644
--- a/speaker/bgp_controller_test.go
+++ b/speaker/bgp_controller_test.go
@@ -23,6 +23,7 @@ import (
 	discovery "k8s.io/api/discovery/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/util/sets"
 	"k8s.io/utils/ptr"
 )
 
@@ -207,6 +208,7 @@ func (s *testK8S) Errorf(_ *v1.Service, evtType string, msg string, args ...inte
 	s.t.Logf("k8s Warning event %q: %s", evtType, fmt.Sprintf(msg, args...))
 	s.loggedWarning = true
 }
+func noopCallback(_ string) {}
 
 func TestBGPSpeakerEPSlices(t *testing.T) {
 	b := &fakeBGP{
@@ -214,9 +216,10 @@ func TestBGPSpeakerEPSlices(t *testing.T) {
 	}
 	newBGP = b.NewSessionManager
 	c, err := newController(controllerConfig{
-		MyNode:        "pandora",
-		DisableLayer2: true,
-		bgpType:       bgpNative,
+		MyNode:                "pandora",
+		DisableLayer2:         true,
+		bgpType:               bgpNative,
+		BGPAdsChangedCallback: noopCallback,
 	})
 	if err != nil {
 		t.Fatalf("creating controller: %s", err)
@@ -1174,9 +1177,10 @@ func TestNodeSelectors(t *testing.T) {
 	}
 	newBGP = b.NewSessionManager
 	c, err := newController(controllerConfig{
-		MyNode:        "pandora",
-		DisableLayer2: true,
-		bgpType:       bgpNative,
+		MyNode:                "pandora",
+		DisableLayer2:         true,
+		bgpType:               bgpNative,
+		BGPAdsChangedCallback: noopCallback,
 	})
 	if err != nil {
 		t.Fatalf("creating controller: %s", err)
@@ -1508,10 +1512,11 @@ func TestShouldAnnounceExcludeLB(t *testing.T) {
 			}},
 		}
 		c1, err := newController(controllerConfig{
-			MyNode:          "iris1",
-			Logger:          log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr)),
-			bgpType:         bgpNative,
-			IgnoreExcludeLB: test.ignoreExcludeFromLB,
+			MyNode:                "iris1",
+			Logger:                log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr)),
+			bgpType:               bgpNative,
+			IgnoreExcludeLB:       test.ignoreExcludeFromLB,
+			BGPAdsChangedCallback: noopCallback,
 		})
 		if err != nil {
 			t.Fatalf("creating controller: %s", err)
@@ -1519,10 +1524,11 @@ func TestShouldAnnounceExcludeLB(t *testing.T) {
 		c1.client = &testK8S{t: t}
 
 		c2, err := newController(controllerConfig{
-			MyNode:          "iris2",
-			Logger:          log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr)),
-			bgpType:         bgpNative,
-			IgnoreExcludeLB: test.ignoreExcludeFromLB,
+			MyNode:                "iris2",
+			Logger:                log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr)),
+			bgpType:               bgpNative,
+			IgnoreExcludeLB:       test.ignoreExcludeFromLB,
+			BGPAdsChangedCallback: noopCallback,
 		})
 		if err != nil {
 			t.Fatalf("creating controller: %s", err)
@@ -1686,3 +1692,304 @@ func TestPasswordForSession(t *testing.T) {
 		})
 	}
 }
+
+func TestPeersForService(t *testing.T) {
+	callbackCounters := map[string]int{}
+	callback := func(key string) {
+		callbackCounters[key]++
+	}
+
+	b := &fakeBGP{
+		t: t,
+	}
+	newBGP = b.NewSessionManager
+	c, err := newController(controllerConfig{
+		MyNode:                "pandora",
+		DisableLayer2:         true,
+		bgpType:               bgpNative,
+		BGPAdsChangedCallback: callback,
+	})
+	if err != nil {
+		t.Fatalf("creating controller: %s", err)
+	}
+	c.client = &testK8S{t: t}
+
+	svc1Name, svc2Name := "test1", "test2"
+	peer1Name, peer2Name := "peer1", "peer2"
+	tests := []struct {
+		desc string
+
+		balancer              string
+		config                *config.Config
+		svc                   *v1.Service
+		eps                   []discovery.EndpointSlice
+		expectedPeers         map[string][]string // svc -> expected peers
+		expectedCallbacked    []string            // the services that should get callbacked
+		expectedNotCallbacked []string            // the services that should not get callbacked
+	}{
+		{
+			desc: "Set the config with 2 peers and 2 pools",
+			config: &config.Config{
+				Peers: map[string]*config.Peer{
+					peer1Name: {
+						Name:          peer1Name,
+						Addr:          net.ParseIP("1.2.3.4"),
+						NodeSelectors: []labels.Selector{labels.Everything()},
+					},
+					peer2Name: {
+						Name:          peer2Name,
+						Addr:          net.ParseIP("1.2.3.5"),
+						NodeSelectors: []labels.Selector{labels.Everything()},
+					},
+				},
+				Pools: &config.Pools{ByName: map[string]*config.Pool{
+					"pool1": {
+						CIDR: []*net.IPNet{ipnet("10.20.30.0/24")},
+						BGPAdvertisements: []*config.BGPAdvertisement{
+							{
+								AggregationLength: 32,
+								Nodes:             map[string]bool{"pandora": true},
+							},
+						},
+					},
+					"pool2": {
+						CIDR: []*net.IPNet{ipnet("10.20.40.0/24")},
+						BGPAdvertisements: []*config.BGPAdvertisement{
+							{
+								AggregationLength: 32,
+								Nodes:             map[string]bool{"pandora": true},
+							},
+						},
+					},
+				}},
+			},
+			expectedPeers:         map[string][]string{svc1Name: {}, svc2Name: {}},
+			expectedCallbacked:    []string{},
+			expectedNotCallbacked: []string{svc1Name, svc2Name},
+		},
+		{
+			desc:     "Add first service",
+			balancer: svc1Name,
+			svc: &v1.Service{
+				Spec: v1.ServiceSpec{
+					Type:                  "LoadBalancer",
+					ExternalTrafficPolicy: "Cluster",
+				},
+				Status: statusAssigned("10.20.30.1"),
+			},
+			eps: []discovery.EndpointSlice{
+				{
+					Endpoints: []discovery.Endpoint{
+						{
+							Addresses: []string{
+								"2.3.4.5",
+							},
+							NodeName: ptr.To("iris"),
+							Conditions: discovery.EndpointConditions{
+								Ready: ptr.To(true),
+							},
+						},
+					},
+				},
+			},
+			expectedPeers:         map[string][]string{svc1Name: {peer1Name, peer2Name}, svc2Name: {}},
+			expectedCallbacked:    []string{svc1Name},
+			expectedNotCallbacked: []string{svc2Name},
+		},
+		{
+			desc:     "Add second service",
+			balancer: svc2Name,
+			svc: &v1.Service{
+				Spec: v1.ServiceSpec{
+					Type:                  "LoadBalancer",
+					ExternalTrafficPolicy: "Cluster",
+				},
+				Status: statusAssigned("10.20.40.1"),
+			},
+			eps: []discovery.EndpointSlice{
+				{
+					Endpoints: []discovery.Endpoint{
+						{
+							Addresses: []string{
+								"2.3.4.5",
+							},
+							NodeName: ptr.To("iris"),
+							Conditions: discovery.EndpointConditions{
+								Ready: ptr.To(true),
+							},
+						},
+					},
+				},
+			},
+			expectedPeers:         map[string][]string{svc1Name: {peer1Name, peer2Name}, svc2Name: {peer1Name, peer2Name}},
+			expectedCallbacked:    []string{svc2Name},
+			expectedNotCallbacked: []string{svc1Name},
+		},
+		{
+			desc: "Advertise service 1 to peer1 only",
+			config: &config.Config{
+				Peers: map[string]*config.Peer{
+					peer1Name: {
+						Name:          peer1Name,
+						Addr:          net.ParseIP("1.2.3.4"),
+						NodeSelectors: []labels.Selector{labels.Everything()},
+					},
+					peer2Name: {
+						Name:          peer2Name,
+						Addr:          net.ParseIP("1.2.3.5"),
+						NodeSelectors: []labels.Selector{labels.Everything()},
+					},
+				},
+				Pools: &config.Pools{ByName: map[string]*config.Pool{
+					"pool1": {
+						CIDR: []*net.IPNet{ipnet("10.20.30.0/24")},
+						BGPAdvertisements: []*config.BGPAdvertisement{
+							{
+								AggregationLength: 32,
+								Nodes:             map[string]bool{"pandora": true},
+								Peers:             []string{peer1Name},
+							},
+						},
+					},
+					"pool2": {
+						CIDR: []*net.IPNet{ipnet("10.20.40.0/24")},
+						BGPAdvertisements: []*config.BGPAdvertisement{
+							{
+								AggregationLength: 32,
+								Nodes:             map[string]bool{"pandora": true},
+							},
+						},
+					},
+				}},
+			},
+			balancer: svc1Name,
+			svc: &v1.Service{
+				Spec: v1.ServiceSpec{
+					Type:                  "LoadBalancer",
+					ExternalTrafficPolicy: "Cluster",
+				},
+				Status: statusAssigned("10.20.30.1"),
+			},
+			eps: []discovery.EndpointSlice{
+				{
+					Endpoints: []discovery.Endpoint{
+						{
+							Addresses: []string{
+								"2.3.4.5",
+							},
+							NodeName: ptr.To("iris"),
+							Conditions: discovery.EndpointConditions{
+								Ready: ptr.To(true),
+							},
+						},
+					},
+				},
+			},
+			expectedPeers:         map[string][]string{svc1Name: {peer1Name}, svc2Name: {peer1Name, peer2Name}},
+			expectedCallbacked:    []string{svc1Name},
+			expectedNotCallbacked: []string{svc2Name},
+		},
+		{
+			desc:                  "Delete first service",
+			balancer:              svc1Name,
+			expectedPeers:         map[string][]string{svc1Name: {}, svc2Name: {peer1Name, peer2Name}},
+			expectedCallbacked:    []string{svc1Name},
+			expectedNotCallbacked: []string{svc2Name},
+		},
+		{
+			desc: "Delete first peer",
+			config: &config.Config{
+				Peers: map[string]*config.Peer{
+					peer2Name: {
+						Name:          peer2Name,
+						Addr:          net.ParseIP("1.2.3.5"),
+						NodeSelectors: []labels.Selector{labels.Everything()},
+					},
+				},
+				Pools: &config.Pools{ByName: map[string]*config.Pool{
+					"pool1": {
+						CIDR: []*net.IPNet{ipnet("10.20.30.0/24")},
+						BGPAdvertisements: []*config.BGPAdvertisement{
+							{
+								AggregationLength: 32,
+								Nodes:             map[string]bool{"pandora": true},
+								Peers:             []string{peer1Name},
+							},
+						},
+					},
+					"pool2": {
+						CIDR: []*net.IPNet{ipnet("10.20.40.0/24")},
+						BGPAdvertisements: []*config.BGPAdvertisement{
+							{
+								AggregationLength: 32,
+								Nodes:             map[string]bool{"pandora": true},
+							},
+						},
+					},
+				}},
+			},
+			balancer: svc2Name,
+			svc: &v1.Service{
+				Spec: v1.ServiceSpec{
+					Type:                  "LoadBalancer",
+					ExternalTrafficPolicy: "Cluster",
+				},
+				Status: statusAssigned("10.20.40.1"),
+			},
+			eps: []discovery.EndpointSlice{
+				{
+					Endpoints: []discovery.Endpoint{
+						{
+							Addresses: []string{
+								"2.3.4.5",
+							},
+							NodeName: ptr.To("iris"),
+							Conditions: discovery.EndpointConditions{
+								Ready: ptr.To(true),
+							},
+						},
+					},
+				},
+			},
+			expectedPeers:         map[string][]string{svc1Name: {}, svc2Name: {peer2Name}},
+			expectedCallbacked:    []string{svc2Name},
+			expectedNotCallbacked: []string{svc1Name},
+		},
+	}
+
+	l := log.NewNopLogger()
+	for _, test := range tests {
+		oldCallbackCounters := map[string]int{}
+		for k, v := range callbackCounters {
+			oldCallbackCounters[k] = v
+		}
+		if test.config != nil {
+			if c.SetConfig(l, test.config) == controllers.SyncStateError {
+				t.Errorf("%q: SetConfig failed", test.desc)
+			}
+		}
+		if test.balancer != "" {
+			if c.SetBalancer(l, test.balancer, test.svc, test.eps) == controllers.SyncStateError {
+				t.Errorf("%q: SetBalancer failed", test.desc)
+			}
+		}
+
+		for svc, wantPeers := range test.expectedPeers {
+			if diff := cmp.Diff(wantPeers, sets.List(c.bgpPeersFetcher(svc))); diff != "" {
+				t.Errorf("%q: unexpected peers for service %s (-want +got)\n%s", test.desc, svc, diff)
+			}
+		}
+
+		for _, svc := range test.expectedNotCallbacked {
+			if oldCallbackCounters[svc] != callbackCounters[svc] {
+				t.Errorf("%q: unexpected callback counters for service %s on NotCallbacked, want %v got %v", test.desc, svc, oldCallbackCounters[svc], callbackCounters[svc])
+			}
+		}
+
+		for _, svc := range test.expectedCallbacked {
+			if oldCallbackCounters[svc]+1 != callbackCounters[svc] {
+				t.Errorf("%q: unexpected callback counters for service %s on Callbacked, want %v got %v", test.desc, svc, oldCallbackCounters[svc]+1, callbackCounters[svc])
+			}
+		}
+	}
+}