Repository: metallb/metallb
Pull request #2643 (2024-12-30 15:35:50)

Implement IPAddressPool Status
<!-- Thanks for sending a pull request!
1. If this is your first time, please read the [contributing guide](https://metallb.universe.tf/community/#contributing)
2. For non-trivial pull requests, please [file an
   issue](https://github.com/metallb/metallb/issues/new) first, and get
   agreement that the change is a good idea, and a general guideline
   for how it should be implemented, before sending code. Large PRs
   that weren't first discussed and agreed upon in an issue won't be
   accepted.
3. If the PR fixes a particular bug, please include the words "Fixed
   #<issue number>" in the PR text, so that the bug auto-closes when
   the PR is merged.
-->

**Is this a BUG FIX or a FEATURE ?**:

/kind feature

**What this PR does / why we need it**:
Adds the IPAddressPool Status as described in https://github.com/metallb/metallb/blob/main/design/crd-status.md#ipaddresspool-status
**Special notes for your reviewer**:

**Release note**:
<!--  Write your release note:
1. Enter your extended release note in the below block. If the PR requires additional action from users switching to the new release, include the string "action required".
2. Follow the instructions for writing a release note from k8s: https://git.k8s.io/community/contributors/guide/release-notes.md
3. If no release note is required, just write "NONE".
-->

```release-note
Add IPAddressPool Status, exposing the available/assignment counters for the pool.
```


fedepaol: We should remember also to add the documentation :sweat_smile: 
fedepaol: LGTM

Patch:
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
index 4d8af036..e195c14f 100644
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -60,7 +60,7 @@ jobs:
         image: [speaker, controller, configmaptocrs]
     steps:
       - name: Set up QEMU
-        uses: docker/setup-qemu-action@v3.2.0
+        uses: docker/setup-qemu-action@v3.4.0
 
       - name: Install Cosign
         uses: sigstore/cosign-installer@main
diff --git a/api/v1beta1/ipaddresspool_types.go b/api/v1beta1/ipaddresspool_types.go
index f954c725..8e9c5506 100644
--- a/api/v1beta1/ipaddresspool_types.go
+++ b/api/v1beta1/ipaddresspool_types.go
@@ -67,8 +67,17 @@ type ServiceAllocation struct {
 
 // IPAddressPoolStatus defines the observed state of IPAddressPool.
 type IPAddressPoolStatus struct {
-	// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
-	// Important: Run "make" to regenerate code after modifying this file
+	// AssignedIPv4 is the number of assigned IPv4 addresses.
+	AssignedIPv4 int64 `json:"assignedIPv4"`
+
+	// AssignedIPv6 is the number of assigned IPv6 addresses.
+	AssignedIPv6 int64 `json:"assignedIPv6"`
+
+	// AvailableIPv4 is the number of available IPv4 addresses.
+	AvailableIPv4 int64 `json:"availableIPv4"`
+
+	// AvailableIPv6 is the number of available IPv6 addresses.
+	AvailableIPv6 int64 `json:"availableIPv6"`
 }
 
 // +kubebuilder:object:root=true
diff --git a/charts/metallb/charts/crds/templates/crds.yaml b/charts/metallb/charts/crds/templates/crds.yaml
index 31468045..383c8fea 100644
--- a/charts/metallb/charts/crds/templates/crds.yaml
+++ b/charts/metallb/charts/crds/templates/crds.yaml
@@ -941,6 +941,28 @@ spec:
               type: object
             status:
               description: IPAddressPoolStatus defines the observed state of IPAddressPool.
+              properties:
+                assignedIPv4:
+                  description: AssignedIPv4 is the number of assigned IPv4 addresses.
+                  format: int64
+                  type: integer
+                assignedIPv6:
+                  description: AssignedIPv6 is the number of assigned IPv6 addresses.
+                  format: int64
+                  type: integer
+                availableIPv4:
+                  description: AvailableIPv4 is the number of available IPv4 addresses.
+                  format: int64
+                  type: integer
+                availableIPv6:
+                  description: AvailableIPv6 is the number of available IPv6 addresses.
+                  format: int64
+                  type: integer
+              required:
+                - assignedIPv4
+                - assignedIPv6
+                - availableIPv4
+                - availableIPv6
               type: object
           required:
             - spec
diff --git a/charts/metallb/templates/rbac.yaml b/charts/metallb/templates/rbac.yaml
index b174cba3..11d2ae04 100644
--- a/charts/metallb/templates/rbac.yaml
+++ b/charts/metallb/templates/rbac.yaml
@@ -141,6 +141,9 @@ rules:
 - apiGroups: ["metallb.io"]
   resources: ["ipaddresspools"]
   verbs: ["get", "list", "watch"]
+- apiGroups: ["metallb.io"]
+  resources: ["ipaddresspools/status"]
+  verbs: ["update"]
 - apiGroups: ["metallb.io"]
   resources: ["bgppeers"]
   verbs: ["get", "list"]
diff --git a/config/crd/bases/metallb.io_ipaddresspools.yaml b/config/crd/bases/metallb.io_ipaddresspools.yaml
index 59c3475c..c3a7d7e4 100644
--- a/config/crd/bases/metallb.io_ipaddresspools.yaml
+++ b/config/crd/bases/metallb.io_ipaddresspools.yaml
@@ -206,6 +206,28 @@ spec:
             type: object
           status:
             description: IPAddressPoolStatus defines the observed state of IPAddressPool.
+            properties:
+              assignedIPv4:
+                description: AssignedIPv4 is the number of assigned IPv4 addresses.
+                format: int64
+                type: integer
+              assignedIPv6:
+                description: AssignedIPv6 is the number of assigned IPv6 addresses.
+                format: int64
+                type: integer
+              availableIPv4:
+                description: AvailableIPv4 is the number of available IPv4 addresses.
+                format: int64
+                type: integer
+              availableIPv6:
+                description: AvailableIPv6 is the number of available IPv6 addresses.
+                format: int64
+                type: integer
+            required:
+            - assignedIPv4
+            - assignedIPv6
+            - availableIPv4
+            - availableIPv6
             type: object
         required:
         - spec
diff --git a/config/manifests/metallb-frr-k8s-prometheus.yaml b/config/manifests/metallb-frr-k8s-prometheus.yaml
index 9b7c9837..ef1d2082 100644
--- a/config/manifests/metallb-frr-k8s-prometheus.yaml
+++ b/config/manifests/metallb-frr-k8s-prometheus.yaml
@@ -1523,6 +1523,28 @@ spec:
             type: object
           status:
             description: IPAddressPoolStatus defines the observed state of IPAddressPool.
+            properties:
+              assignedIPv4:
+                description: AssignedIPv4 is the number of assigned IPv4 addresses.
+                format: int64
+                type: integer
+              assignedIPv6:
+                description: AssignedIPv6 is the number of assigned IPv6 addresses.
+                format: int64
+                type: integer
+              availableIPv4:
+                description: AvailableIPv4 is the number of available IPv4 addresses.
+                format: int64
+                type: integer
+              availableIPv6:
+                description: AvailableIPv6 is the number of available IPv6 addresses.
+                format: int64
+                type: integer
+            required:
+            - assignedIPv4
+            - assignedIPv6
+            - availableIPv4
+            - availableIPv6
             type: object
         required:
         - spec
@@ -1983,6 +2005,12 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - ipaddresspools/status
+  verbs:
+  - update
 - apiGroups:
   - metallb.io
   resources:
diff --git a/config/manifests/metallb-frr-k8s.yaml b/config/manifests/metallb-frr-k8s.yaml
index 943cc597..29f78f49 100644
--- a/config/manifests/metallb-frr-k8s.yaml
+++ b/config/manifests/metallb-frr-k8s.yaml
@@ -1523,6 +1523,28 @@ spec:
             type: object
           status:
             description: IPAddressPoolStatus defines the observed state of IPAddressPool.
+            properties:
+              assignedIPv4:
+                description: AssignedIPv4 is the number of assigned IPv4 addresses.
+                format: int64
+                type: integer
+              assignedIPv6:
+                description: AssignedIPv6 is the number of assigned IPv6 addresses.
+                format: int64
+                type: integer
+              availableIPv4:
+                description: AvailableIPv4 is the number of available IPv4 addresses.
+                format: int64
+                type: integer
+              availableIPv6:
+                description: AvailableIPv6 is the number of available IPv6 addresses.
+                format: int64
+                type: integer
+            required:
+            - assignedIPv4
+            - assignedIPv6
+            - availableIPv4
+            - availableIPv6
             type: object
         required:
         - spec
@@ -1983,6 +2005,12 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - ipaddresspools/status
+  verbs:
+  - update
 - apiGroups:
   - metallb.io
   resources:
diff --git a/config/manifests/metallb-frr-prometheus.yaml b/config/manifests/metallb-frr-prometheus.yaml
index b86b1a63..d6203147 100644
--- a/config/manifests/metallb-frr-prometheus.yaml
+++ b/config/manifests/metallb-frr-prometheus.yaml
@@ -979,6 +979,28 @@ spec:
             type: object
           status:
             description: IPAddressPoolStatus defines the observed state of IPAddressPool.
+            properties:
+              assignedIPv4:
+                description: AssignedIPv4 is the number of assigned IPv4 addresses.
+                format: int64
+                type: integer
+              assignedIPv6:
+                description: AssignedIPv6 is the number of assigned IPv6 addresses.
+                format: int64
+                type: integer
+              availableIPv4:
+                description: AvailableIPv4 is the number of available IPv4 addresses.
+                format: int64
+                type: integer
+              availableIPv6:
+                description: AvailableIPv6 is the number of available IPv6 addresses.
+                format: int64
+                type: integer
+            required:
+            - assignedIPv4
+            - assignedIPv6
+            - availableIPv4
+            - availableIPv6
             type: object
         required:
         - spec
@@ -1426,6 +1448,12 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - ipaddresspools/status
+  verbs:
+  - update
 - apiGroups:
   - metallb.io
   resources:
diff --git a/config/manifests/metallb-frr.yaml b/config/manifests/metallb-frr.yaml
index bf457f05..156f4e48 100644
--- a/config/manifests/metallb-frr.yaml
+++ b/config/manifests/metallb-frr.yaml
@@ -979,6 +979,28 @@ spec:
             type: object
           status:
             description: IPAddressPoolStatus defines the observed state of IPAddressPool.
+            properties:
+              assignedIPv4:
+                description: AssignedIPv4 is the number of assigned IPv4 addresses.
+                format: int64
+                type: integer
+              assignedIPv6:
+                description: AssignedIPv6 is the number of assigned IPv6 addresses.
+                format: int64
+                type: integer
+              availableIPv4:
+                description: AvailableIPv4 is the number of available IPv4 addresses.
+                format: int64
+                type: integer
+              availableIPv6:
+                description: AvailableIPv6 is the number of available IPv6 addresses.
+                format: int64
+                type: integer
+            required:
+            - assignedIPv4
+            - assignedIPv6
+            - availableIPv4
+            - availableIPv6
             type: object
         required:
         - spec
@@ -1426,6 +1448,12 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - ipaddresspools/status
+  verbs:
+  - update
 - apiGroups:
   - metallb.io
   resources:
diff --git a/config/manifests/metallb-native-prometheus.yaml b/config/manifests/metallb-native-prometheus.yaml
index 54cee476..73076c3d 100644
--- a/config/manifests/metallb-native-prometheus.yaml
+++ b/config/manifests/metallb-native-prometheus.yaml
@@ -979,6 +979,28 @@ spec:
             type: object
           status:
             description: IPAddressPoolStatus defines the observed state of IPAddressPool.
+            properties:
+              assignedIPv4:
+                description: AssignedIPv4 is the number of assigned IPv4 addresses.
+                format: int64
+                type: integer
+              assignedIPv6:
+                description: AssignedIPv6 is the number of assigned IPv6 addresses.
+                format: int64
+                type: integer
+              availableIPv4:
+                description: AvailableIPv4 is the number of available IPv4 addresses.
+                format: int64
+                type: integer
+              availableIPv6:
+                description: AvailableIPv6 is the number of available IPv6 addresses.
+                format: int64
+                type: integer
+            required:
+            - assignedIPv4
+            - assignedIPv6
+            - availableIPv4
+            - availableIPv6
             type: object
         required:
         - spec
@@ -1426,6 +1448,12 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - ipaddresspools/status
+  verbs:
+  - update
 - apiGroups:
   - metallb.io
   resources:
diff --git a/config/manifests/metallb-native.yaml b/config/manifests/metallb-native.yaml
index 92768781..22db4d36 100644
--- a/config/manifests/metallb-native.yaml
+++ b/config/manifests/metallb-native.yaml
@@ -979,6 +979,28 @@ spec:
             type: object
           status:
             description: IPAddressPoolStatus defines the observed state of IPAddressPool.
+            properties:
+              assignedIPv4:
+                description: AssignedIPv4 is the number of assigned IPv4 addresses.
+                format: int64
+                type: integer
+              assignedIPv6:
+                description: AssignedIPv6 is the number of assigned IPv6 addresses.
+                format: int64
+                type: integer
+              availableIPv4:
+                description: AvailableIPv4 is the number of available IPv4 addresses.
+                format: int64
+                type: integer
+              availableIPv6:
+                description: AvailableIPv6 is the number of available IPv6 addresses.
+                format: int64
+                type: integer
+            required:
+            - assignedIPv4
+            - assignedIPv6
+            - availableIPv4
+            - availableIPv6
             type: object
         required:
         - spec
@@ -1426,6 +1448,12 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - ipaddresspools/status
+  verbs:
+  - update
 - apiGroups:
   - metallb.io
   resources:
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 1fc501d1..832474bf 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -281,6 +281,12 @@ rules:
       - get
       - list
       - watch
+  - apiGroups:
+      - metallb.io
+    resources:
+      - ipaddresspools/status
+    verbs:
+      - update
   - apiGroups:
       - metallb.io
     resources:
diff --git a/controller/main.go b/controller/main.go
index 3cf78ebb..bfcb9588 100644
--- a/controller/main.go
+++ b/controller/main.go
@@ -27,6 +27,7 @@ import (
 	"go.universe.tf/metallb/internal/k8s/controllers"
 	"go.universe.tf/metallb/internal/logging"
 	"go.universe.tf/metallb/internal/version"
+	"sigs.k8s.io/controller-runtime/pkg/event"
 
 	"github.com/go-kit/log"
 	"github.com/go-kit/log/level"
@@ -203,8 +204,11 @@ func main() {
 		*namespace = string(bs)
 	}
 
+	poolStatusChan := make(chan event.GenericEvent)
 	c := &controller{
-		ips: allocator.New(),
+		ips: allocator.New(func(name string) {
+			poolStatusChan <- controllers.NewPoolStatusEvent(*namespace, name)
+		}),
 	}
 
 	bgpType, present := os.LookupEnv("METALLB_BGP_TYPE")
@@ -235,6 +239,8 @@ func main() {
 		CertDir:             *certDir,
 		CertServiceName:     *certServiceName,
 		LoadBalancerClass:   *loadBalancerClass,
+		PoolStatusChan:      poolStatusChan,
+		PoolCountersFetcher: c.ips.CountersForPool,
 	}
 	switch *webhookMode {
 	case "enabled":
diff --git a/go.mod b/go.mod
index e58a8558..e675e81f 100644
--- a/go.mod
+++ b/go.mod
@@ -23,7 +23,7 @@ require (
 	k8s.io/apiextensions-apiserver v0.32.1
 	k8s.io/apimachinery v0.32.2
 	k8s.io/client-go v0.32.2
-	k8s.io/component-base v0.32.1
+	k8s.io/component-base v0.32.2
 	k8s.io/klog v1.0.0
 	k8s.io/utils v0.0.0-20241210054802-24370beab758
 	sigs.k8s.io/controller-runtime v0.20.1
diff --git a/go.sum b/go.sum
index 41f35c9c..4f7934bc 100644
--- a/go.sum
+++ b/go.sum
@@ -371,8 +371,8 @@ k8s.io/apimachinery v0.32.2 h1:yoQBR9ZGkA6Rgmhbp/yuT9/g+4lxtsGYwW6dR6BDPLQ=
 k8s.io/apimachinery v0.32.2/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
 k8s.io/client-go v0.32.2 h1:4dYCD4Nz+9RApM2b/3BtVvBHw54QjMFUl1OLcJG5yOA=
 k8s.io/client-go v0.32.2/go.mod h1:fpZ4oJXclZ3r2nDOv+Ux3XcJutfrwjKTCHz2H3sww94=
-k8s.io/component-base v0.32.1 h1:/5IfJ0dHIKBWysGV0yKTFfacZ5yNV1sulPh3ilJjRZk=
-k8s.io/component-base v0.32.1/go.mod h1:j1iMMHi/sqAHeG5z+O9BFNCF698a1u0186zkjMZQ28w=
+k8s.io/component-base v0.32.2 h1:1aUL5Vdmu7qNo4ZsE+569PV5zFatM9hl+lb3dEea2zU=
+k8s.io/component-base v0.32.2/go.mod h1:PXJ61Vx9Lg+P5mS8TLd7bCIr+eMJRQTyXe8KvkrvJq0=
 k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=
 k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
 k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
diff --git a/internal/allocator/allocator.go b/internal/allocator/allocator.go
index dfc68b3e..971ff70c 100644
--- a/internal/allocator/allocator.go
+++ b/internal/allocator/allocator.go
@@ -9,6 +9,7 @@ import (
 	"net"
 	"sort"
 	"strings"
+	"sync"
 
 	"go.universe.tf/metallb/internal/config"
 	"go.universe.tf/metallb/internal/ipfamily"
@@ -29,6 +30,10 @@ type Allocator struct {
 	poolIPsInUse    map[string]map[string]int  // poolName -> ip.String() -> number of users
 	poolIPV4InUse   map[string]map[string]int  // poolName -> ipv4.String() -> number of users
 	poolIPV6InUse   map[string]map[string]int  // poolName -> ipv6.String() -> number of users
+
+	poolToCounters          map[string]PoolCounters // poolName -> Counters
+	countersMutex           sync.RWMutex
+	countersChangedCallback func(string)
 }
 
 // Port represents one port in use by a service.
@@ -54,30 +59,49 @@ type alloc struct {
 	key
 }
 
+type PoolCounters struct {
+	AssignedIPv4  int64
+	AssignedIPv6  int64
+	AvailableIPv4 int64
+	AvailableIPv6 int64
+}
+
 // New returns an Allocator managing no pools.
-func New() *Allocator {
+func New(countersCallback func(string)) *Allocator {
 	return &Allocator{
 		pools: &config.Pools{ByName: map[string]*config.Pool{}},
 
-		allocated:       map[string]*alloc{},
-		sharingKeyForIP: map[string]*key{},
-		portsInUse:      map[string]map[Port]string{},
-		servicesOnIP:    map[string]map[string]bool{},
-		poolIPsInUse:    map[string]map[string]int{},
-		poolIPV4InUse:   map[string]map[string]int{},
-		poolIPV6InUse:   map[string]map[string]int{},
+		allocated:               map[string]*alloc{},
+		sharingKeyForIP:         map[string]*key{},
+		portsInUse:              map[string]map[Port]string{},
+		servicesOnIP:            map[string]map[string]bool{},
+		poolIPsInUse:            map[string]map[string]int{},
+		poolIPV4InUse:           map[string]map[string]int{},
+		poolIPV6InUse:           map[string]map[string]int{},
+		poolToCounters:          map[string]PoolCounters{},
+		countersMutex:           sync.RWMutex{},
+		countersChangedCallback: countersCallback,
 	}
 }
 
 // SetPools updates the set of address pools that the allocator owns.
 func (a *Allocator) SetPools(pools *config.Pools) {
+	refreshPools := []string{}
+	defer func() {
+		for _, p := range refreshPools {
+			a.countersChangedCallback(p)
+		}
+	}()
+
+	a.countersMutex.Lock()
 	for n := range a.pools.ByName {
 		if pools.ByName[n] == nil {
-			stats.poolCapacity.DeleteLabelValues(n)
-			stats.poolActive.DeleteLabelValues(n)
-			stats.poolAllocated.DeleteLabelValues(n)
+			deleteStatsFor(n)
+			delete(a.poolToCounters, n)
+			refreshPools = append(refreshPools, n)
 		}
 	}
+	a.countersMutex.Unlock()
 
 	a.pools = pools
 
@@ -98,14 +122,9 @@ func (a *Allocator) SetPools(pools *config.Pools) {
 	}
 
 	// Refresh or initiate stats
-	for n, p := range a.pools.ByName {
-		total, ipv4, ipv6 := poolCount(p)
-		stats.poolCapacity.WithLabelValues(n).Set(float64(total))
-		stats.ipv4PoolCapacity.WithLabelValues(n).Set(float64(ipv4))
-		stats.ipv6PoolCapacity.WithLabelValues(n).Set(float64(ipv6))
-		stats.poolActive.WithLabelValues(n).Set(float64(len(a.poolIPsInUse[n])))
-		stats.ipv4PoolActive.WithLabelValues(n).Set(float64(len(a.poolIPV4InUse[n])))
-		stats.ipv6PoolActive.WithLabelValues(n).Set(float64(len(a.poolIPV6InUse[n])))
+	for _, p := range a.pools.ByName {
+		a.updatePoolStats(p)
+		refreshPools = append(refreshPools, p.Name)
 	}
 }
 
@@ -143,13 +162,8 @@ func (a *Allocator) assign(svc string, alloc *alloc) {
 			a.poolIPV4InUse[alloc.pool][ip.String()]++
 		}
 	}
-	total, ipv4, ipv6 := poolCount(a.pools.ByName[alloc.pool])
-	stats.poolCapacity.WithLabelValues(alloc.pool).Set(float64(total))
-	stats.ipv4PoolCapacity.WithLabelValues(alloc.pool).Set(float64(ipv4))
-	stats.ipv6PoolCapacity.WithLabelValues(alloc.pool).Set(float64(ipv6))
-	stats.poolActive.WithLabelValues(alloc.pool).Set(float64(len(a.poolIPsInUse[alloc.pool])))
-	stats.ipv4PoolActive.WithLabelValues(alloc.pool).Set(float64(len(a.poolIPV4InUse[alloc.pool])))
-	stats.ipv6PoolActive.WithLabelValues(alloc.pool).Set(float64(len(a.poolIPV6InUse[alloc.pool])))
+	a.updatePoolStats(a.pools.ByName[alloc.pool])
+	a.countersChangedCallback(alloc.pool)
 }
 
 // Assign assigns the requested ip to svc, if the assignment is
@@ -240,9 +254,14 @@ func (a *Allocator) Unassign(svc string) {
 			delete(a.poolIPV6InUse[al.pool], ip.String())
 		}
 	}
-	stats.poolActive.WithLabelValues(al.pool).Set(float64(len(a.poolIPsInUse[al.pool])))
-	stats.ipv4PoolActive.WithLabelValues(al.pool).Set(float64(len(a.poolIPV4InUse[al.pool])))
-	stats.ipv6PoolActive.WithLabelValues(al.pool).Set(float64(len(a.poolIPV6InUse[al.pool])))
+
+	if _, ok := a.pools.ByName[al.pool]; !ok {
+		deleteStatsFor(al.pool)
+		return
+	}
+
+	a.updatePoolStats(a.pools.ByName[al.pool])
+	a.countersChangedCallback(al.pool)
 }
 
 // getFreeIPsFromPool determines, with best effort, an ipv4 and an ipv6 available from the provided pool.
@@ -546,6 +565,12 @@ func (a *Allocator) PoolForIP(ips []net.IP) *config.Pool {
 	return poolFor(a.pools.ByName, ips)
 }
 
+func (a *Allocator) CountersForPool(name string) PoolCounters {
+	a.countersMutex.RLock()
+	defer a.countersMutex.RUnlock()
+	return a.poolToCounters[name]
+}
+
 func sortPools(pools []*config.Pool) {
 	// A lower value for pool priority equals a higher priority and sort
 	// pools from higher to low priority. when no priority (0) set on
@@ -712,3 +737,21 @@ func ipPolicyForService(svc *v1.Service) v1.IPFamilyPolicy {
 	}
 	return serviceIPFamilyPolicy
 }
+
+func (a *Allocator) updatePoolStats(p *config.Pool) {
+	a.countersMutex.Lock()
+	defer a.countersMutex.Unlock()
+	total, ipv4, ipv6 := poolCount(p)
+	stats.poolCapacity.WithLabelValues(p.Name).Set(float64(total))
+	stats.ipv4PoolCapacity.WithLabelValues(p.Name).Set(float64(ipv4))
+	stats.ipv6PoolCapacity.WithLabelValues(p.Name).Set(float64(ipv6))
+	stats.poolActive.WithLabelValues(p.Name).Set(float64(len(a.poolIPsInUse[p.Name])))
+	stats.ipv4PoolActive.WithLabelValues(p.Name).Set(float64(len(a.poolIPV4InUse[p.Name])))
+	stats.ipv6PoolActive.WithLabelValues(p.Name).Set(float64(len(a.poolIPV6InUse[p.Name])))
+	a.poolToCounters[p.Name] = PoolCounters{
+		AvailableIPv4: ipv4 - int64(len(a.poolIPV4InUse[p.Name])),
+		AvailableIPv6: ipv6 - int64(len(a.poolIPV6InUse[p.Name])),
+		AssignedIPv4:  int64(len(a.poolIPV4InUse[p.Name])),
+		AssignedIPv6:  int64(len(a.poolIPV6InUse[p.Name])),
+	}
+}
diff --git a/internal/allocator/stats.go b/internal/allocator/stats.go
index 3106d9d1..f6928b92 100644
--- a/internal/allocator/stats.go
+++ b/internal/allocator/stats.go
@@ -71,6 +71,16 @@ var stats = struct {
 	}),
 }
 
+func deleteStatsFor(pool string) {
+	stats.poolCapacity.DeleteLabelValues(pool)
+	stats.ipv4PoolCapacity.DeleteLabelValues(pool)
+	stats.ipv6PoolCapacity.DeleteLabelValues(pool)
+	stats.poolActive.DeleteLabelValues(pool)
+	stats.poolAllocated.DeleteLabelValues(pool)
+	stats.ipv4PoolActive.DeleteLabelValues(pool)
+	stats.ipv6PoolActive.DeleteLabelValues(pool)
+}
+
 func init() {
 	prometheus.MustRegister(stats.poolCapacity)
 	prometheus.MustRegister(stats.ipv4PoolCapacity)
diff --git a/internal/config/config.go b/internal/config/config.go
index 4efa660a..99bd24b8 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -31,6 +31,8 @@ import (
 	metallbv1beta1 "go.universe.tf/metallb/api/v1beta1"
 	metallbv1beta2 "go.universe.tf/metallb/api/v1beta2"
 	"go.universe.tf/metallb/internal/bgp/community"
+	"go.universe.tf/metallb/internal/ipfamily"
+	k8snodes "go.universe.tf/metallb/internal/k8s/nodes"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
@@ -324,6 +326,13 @@ func poolsFor(resources ClusterResources) (*Pools, error) {
 					return nil, fmt.Errorf("CIDR %q in pool %q overlaps with already defined CIDR %q", cidr, p.Name, m)
 				}
 			}
+			// Check pool CIDR is not overlapping with Node IPs
+			nodeIps := k8snodes.NodeIPsForFamily(resources.Nodes, ipfamily.ForCIDR(cidr))
+			for _, nodeIP := range nodeIps {
+				if cidr.Contains(nodeIP) {
+					return nil, fmt.Errorf("pool cidr %q contains nodeIp %q", cidr, nodeIP)
+				}
+			}
 			allCIDRs = append(allCIDRs, cidr)
 		}
 
diff --git a/internal/k8s/controllers/pool_controller.go b/internal/k8s/controllers/pool_controller.go
index d072c00d..d1041163 100644
--- a/internal/k8s/controllers/pool_controller.go
+++ b/internal/k8s/controllers/pool_controller.go
@@ -116,7 +116,7 @@ func (r *PoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.
 func (r *PoolReconciler) SetupWithManager(mgr ctrl.Manager) error {
 	p := predicate.Funcs{
 		UpdateFunc: func(e event.UpdateEvent) bool {
-			return filterNodeEvent(e) && filterNamespaceEvent(e)
+			return filterNodeEvent(e) && filterNamespaceEvent(e) && filterPoolStatusEvent(e)
 		},
 	}
 	return ctrl.NewControllerManagedBy(mgr).
@@ -126,3 +126,17 @@ func (r *PoolReconciler) SetupWithManager(mgr ctrl.Manager) error {
 		WithEventFilter(p).
 		Complete(r)
 }
+
+func filterPoolStatusEvent(e event.UpdateEvent) bool {
+	_, ok := e.ObjectOld.(*metallbv1beta1.IPAddressPool)
+	if !ok {
+		return true
+	}
+
+	_, ok = e.ObjectNew.(*metallbv1beta1.IPAddressPool)
+	if !ok {
+		return true
+	}
+
+	return predicate.GenerationChangedPredicate{}.Update(e)
+}
diff --git a/internal/k8s/controllers/pool_status_controller.go b/internal/k8s/controllers/pool_status_controller.go
new file mode 100644
index 00000000..822ebb24
--- /dev/null
+++ b/internal/k8s/controllers/pool_status_controller.go
@@ -0,0 +1,100 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package controllers
+
+import (
+	"context"
+	"reflect"
+
+	"github.com/go-kit/log"
+	"github.com/go-kit/log/level"
+	"go.universe.tf/metallb/api/v1beta1"
+	"go.universe.tf/metallb/internal/allocator"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"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/source"
+)
+
+type PoolCountersFetcher func(string) allocator.PoolCounters
+
+type poolStatusEvent struct {
+	metav1.TypeMeta
+	metav1.ObjectMeta
+}
+
+func (evt *poolStatusEvent) DeepCopyObject() runtime.Object {
+	res := new(poolStatusEvent)
+	res.Name = evt.Name
+	res.Namespace = evt.Namespace
+	return res
+}
+
+func NewPoolStatusEvent(namespace, name string) event.GenericEvent {
+	evt := poolStatusEvent{}
+	evt.Name = name
+	evt.Namespace = namespace
+	return event.GenericEvent{Object: &evt}
+}
+
+type PoolStatusReconciler struct {
+	client.Client
+	Logger          log.Logger
+	CountersFetcher PoolCountersFetcher
+	ReconcileChan   <-chan event.GenericEvent
+}
+
+func (r *PoolStatusReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	level.Info(r.Logger).Log("controller", "PoolStatusReconciler", "start reconcile", req.NamespacedName.String())
+	defer level.Info(r.Logger).Log("controller", "PoolStatusReconciler", "end reconcile", req.NamespacedName.String())
+
+	var pool v1beta1.IPAddressPool
+	err := r.Get(ctx, req.NamespacedName, &pool)
+	if apierrors.IsNotFound(err) {
+		return ctrl.Result{}, nil
+	}
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+
+	c := r.CountersFetcher(pool.Name)
+
+	newStatus := v1beta1.IPAddressPoolStatus{
+		AssignedIPv4:  c.AssignedIPv4,
+		AssignedIPv6:  c.AssignedIPv6,
+		AvailableIPv4: c.AvailableIPv4,
+		AvailableIPv6: c.AvailableIPv6,
+	}
+
+	if reflect.DeepEqual(pool.Status, newStatus) {
+		return ctrl.Result{}, nil
+	}
+
+	pool.Status = newStatus
+	err = r.Client.Status().Update(ctx, &pool)
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+
+	return ctrl.Result{}, nil
+}
+
+func (r *PoolStatusReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	p := predicate.Funcs{
+		UpdateFunc: func(e event.UpdateEvent) bool {
+			return !filterPoolStatusEvent(e)
+		},
+	}
+
+	return ctrl.NewControllerManagedBy(mgr).
+		Named("PoolStatusController").
+		For(&v1beta1.IPAddressPool{}).
+		WithEventFilter(p).
+		WatchesRawSource(source.Channel(r.ReconcileChan, &handler.EnqueueRequestForObject{})).
+		Complete(r)
+}
diff --git a/internal/k8s/k8s.go b/internal/k8s/k8s.go
index 4a1019f9..34fb99ea 100644
--- a/internal/k8s/k8s.go
+++ b/internal/k8s/k8s.go
@@ -124,6 +124,8 @@ type Config struct {
 	Layer2StatusFetcher controllers.L2StatusFetcher
 	BGPStatusChan       <-chan event.GenericEvent
 	BGPPeersFetcher     controllers.PeersForService
+	PoolStatusChan      <-chan event.GenericEvent
+	PoolCountersFetcher controllers.PoolCountersFetcher
 }
 
 // New connects to masterAddr, using kubeconfig to authenticate.
@@ -212,6 +214,16 @@ func New(cfg *Config) (*Client, error) {
 			level.Error(c.logger).Log("error", err, "unable to create controller", "config")
 			return nil, errors.Join(err, errors.New("failed to create config reconciler"))
 		}
+
+		if err = (&controllers.PoolStatusReconciler{
+			Client:          mgr.GetClient(),
+			Logger:          cfg.Logger,
+			CountersFetcher: cfg.PoolCountersFetcher,
+			ReconcileChan:   cfg.PoolStatusChan,
+		}).SetupWithManager(mgr); err != nil {
+			level.Error(c.logger).Log("error", err, "unable to create controller", "config")
+			return nil, errors.Join(err, errors.New("failed to create pool status reconciler"))
+		}
 	}
 
 	if cfg.NodeChanged != nil {
diff --git a/internal/k8s/nodes/nodes.go b/internal/k8s/nodes/nodes.go
index 1c99f460..be7fba2c 100644
--- a/internal/k8s/nodes/nodes.go
+++ b/internal/k8s/nodes/nodes.go
@@ -4,7 +4,9 @@ package nodes
 
 import (
 	"errors"
+	"net"
 
+	"go.universe.tf/metallb/internal/ipfamily"
 	corev1 "k8s.io/api/core/v1"
 )
 
@@ -59,3 +61,20 @@ func IsNodeExcludedFromBalancers(n *corev1.Node) bool {
 	}
 	return false
 }
+
+// NodeIPsForFamily returns all input node nodeIP based on ipfamily.
+func NodeIPsForFamily(nodes []corev1.Node, family ipfamily.Family) []net.IP {
+	var nodeIPs []net.IP
+	for _, n := range nodes {
+		for _, a := range n.Status.Addresses {
+			if a.Type == corev1.NodeInternalIP {
+				nodeIP := net.ParseIP(a.Address)
+				if family != ipfamily.DualStack && ipfamily.ForAddress(nodeIP) != family {
+					continue
+				}
+				nodeIPs = append(nodeIPs, nodeIP)
+			}
+		}
+	}
+	return nodeIPs
+}
diff --git a/internal/k8s/webhooks/webhookv1beta1/ipaddresspool_webhook.go b/internal/k8s/webhooks/webhookv1beta1/ipaddresspool_webhook.go
index ec797034..3f30ae95 100644
--- a/internal/k8s/webhooks/webhookv1beta1/ipaddresspool_webhook.go
+++ b/internal/k8s/webhooks/webhookv1beta1/ipaddresspool_webhook.go
@@ -104,8 +104,13 @@ func validateIPAddressPoolCreate(ipAddress *v1beta1.IPAddressPool) error {
 		return err
 	}
 
+	nodes, err := getExistingNodes()
+	if err != nil {
+		return err
+	}
+
 	toValidate := ipAddressListWithUpdate(existingIPAddressPoolList, ipAddress)
-	err = Validator.Validate(toValidate)
+	err = Validator.Validate(toValidate, nodes)
 	if err != nil {
 		level.Error(Logger).Log("webhook", "ipAddress", "action", "create", "name", ipAddress.Name, "namespace", ipAddress.Namespace, "error", err)
 		return err
@@ -122,8 +127,13 @@ func validateIPAddressPoolUpdate(ipAddress *v1beta1.IPAddressPool, _ *v1beta1.IP
 		return err
 	}
 
+	nodes, err := getExistingNodes()
+	if err != nil {
+		return err
+	}
+
 	toValidate := ipAddressListWithUpdate(existingIPAddressPoolList, ipAddress)
-	err = Validator.Validate(toValidate)
+	err = Validator.Validate(toValidate, nodes)
 	if err != nil {
 		level.Error(Logger).Log("webhook", "ipAddress", "action", "update", "name", ipAddress.Name, "namespace", ipAddress.Namespace, "error", err)
 		return err
diff --git a/tasks.py b/tasks.py
index f2bdae0b..2507cf53 100644
--- a/tasks.py
+++ b/tasks.py
@@ -1530,7 +1530,7 @@ def fetch_kubectl():
     curl_command = "curl -o {} -LO https://dl.k8s.io/release/{}/bin/$(go env GOOS)/$(go env GOARCH)/kubectl".format(
         kubectl_path, kubectl_version
     )
-    get_version_command = f"{kubectl_path} version --short"
+    get_version_command = f"{kubectl_path} version"
     fetch_dependency(
         kubectl_path,
         kubectl_version,


Test patch:
diff --git a/configmaptocrs/testdata/configmap-data/config-data-address-pools.golden b/configmaptocrs/testdata/configmap-data/config-data-address-pools.golden
index 9e9dc473..2ca944ec 100644
--- a/configmaptocrs/testdata/configmap-data/config-data-address-pools.golden
+++ b/configmaptocrs/testdata/configmap-data/config-data-address-pools.golden
@@ -11,7 +11,11 @@ spec:
   - fc00:f853:0ccd:e799::/124
   autoAssign: true
   avoidBuggyIPs: true
-status: {}
+status:
+  assignedIPv4: 0
+  assignedIPv6: 0
+  availableIPv4: 0
+  availableIPv6: 0
 ---
 apiVersion: metallb.io/v1beta1
 kind: IPAddressPool
@@ -22,7 +26,11 @@ metadata:
 spec:
   addresses:
   - 192.168.1.240/28
-status: {}
+status:
+  assignedIPv4: 0
+  assignedIPv6: 0
+  availableIPv4: 0
+  availableIPv6: 0
 ---
 apiVersion: metallb.io/v1beta1
 kind: BGPAdvertisement
diff --git a/configmaptocrs/testdata/configmap-data/config-data-convert-names.golden b/configmaptocrs/testdata/configmap-data/config-data-convert-names.golden
index 9bd7b7af..76f5570f 100644
--- a/configmaptocrs/testdata/configmap-data/config-data-convert-names.golden
+++ b/configmaptocrs/testdata/configmap-data/config-data-convert-names.golden
@@ -11,7 +11,11 @@ spec:
   - fc00:f853:0ccd:e799::/124
   autoAssign: true
   avoidBuggyIPs: true
-status: {}
+status:
+  assignedIPv4: 0
+  assignedIPv6: 0
+  availableIPv4: 0
+  availableIPv6: 0
 ---
 apiVersion: metallb.io/v1beta1
 kind: IPAddressPool
@@ -22,7 +26,11 @@ metadata:
 spec:
   addresses:
   - 192.168.1.240/28
-status: {}
+status:
+  assignedIPv4: 0
+  assignedIPv6: 0
+  availableIPv4: 0
+  availableIPv6: 0
 ---
 apiVersion: metallb.io/v1beta1
 kind: BGPAdvertisement
diff --git a/configmaptocrs/testdata/configmap-data/config-data-full.golden b/configmaptocrs/testdata/configmap-data/config-data-full.golden
index 693457a6..47c392c6 100644
--- a/configmaptocrs/testdata/configmap-data/config-data-full.golden
+++ b/configmaptocrs/testdata/configmap-data/config-data-full.golden
@@ -40,7 +40,11 @@ spec:
   - 192.168.0.150-192.168.0.200
   autoAssign: true
   avoidBuggyIPs: true
-status: {}
+status:
+  assignedIPv4: 0
+  assignedIPv6: 0
+  availableIPv4: 0
+  availableIPv6: 0
 ---
 apiVersion: metallb.io/v1beta1
 kind: IPAddressPool
@@ -53,7 +57,11 @@ spec:
   - 192.168.11.0/24
   - 192.168.1.150-192.168.1.200
   avoidBuggyIPs: true
-status: {}
+status:
+  assignedIPv4: 0
+  assignedIPv6: 0
+  availableIPv4: 0
+  availableIPv6: 0
 ---
 apiVersion: metallb.io/v1beta1
 kind: BGPAdvertisement
diff --git a/configmaptocrs/testdata/configmap-yaml/config-yaml-address-pools.golden b/configmaptocrs/testdata/configmap-yaml/config-yaml-address-pools.golden
index 9e9dc473..2ca944ec 100644
--- a/configmaptocrs/testdata/configmap-yaml/config-yaml-address-pools.golden
+++ b/configmaptocrs/testdata/configmap-yaml/config-yaml-address-pools.golden
@@ -11,7 +11,11 @@ spec:
   - fc00:f853:0ccd:e799::/124
   autoAssign: true
   avoidBuggyIPs: true
-status: {}
+status:
+  assignedIPv4: 0
+  assignedIPv6: 0
+  availableIPv4: 0
+  availableIPv6: 0
 ---
 apiVersion: metallb.io/v1beta1
 kind: IPAddressPool
@@ -22,7 +26,11 @@ metadata:
 spec:
   addresses:
   - 192.168.1.240/28
-status: {}
+status:
+  assignedIPv4: 0
+  assignedIPv6: 0
+  availableIPv4: 0
+  availableIPv6: 0
 ---
 apiVersion: metallb.io/v1beta1
 kind: BGPAdvertisement
diff --git a/configmaptocrs/testdata/configmap-yaml/config-yaml-convert-names.golden b/configmaptocrs/testdata/configmap-yaml/config-yaml-convert-names.golden
index 9bd7b7af..76f5570f 100644
--- a/configmaptocrs/testdata/configmap-yaml/config-yaml-convert-names.golden
+++ b/configmaptocrs/testdata/configmap-yaml/config-yaml-convert-names.golden
@@ -11,7 +11,11 @@ spec:
   - fc00:f853:0ccd:e799::/124
   autoAssign: true
   avoidBuggyIPs: true
-status: {}
+status:
+  assignedIPv4: 0
+  assignedIPv6: 0
+  availableIPv4: 0
+  availableIPv6: 0
 ---
 apiVersion: metallb.io/v1beta1
 kind: IPAddressPool
@@ -22,7 +26,11 @@ metadata:
 spec:
   addresses:
   - 192.168.1.240/28
-status: {}
+status:
+  assignedIPv4: 0
+  assignedIPv6: 0
+  availableIPv4: 0
+  availableIPv6: 0
 ---
 apiVersion: metallb.io/v1beta1
 kind: BGPAdvertisement
diff --git a/configmaptocrs/testdata/configmap-yaml/config-yaml-full.golden b/configmaptocrs/testdata/configmap-yaml/config-yaml-full.golden
index 693457a6..47c392c6 100644
--- a/configmaptocrs/testdata/configmap-yaml/config-yaml-full.golden
+++ b/configmaptocrs/testdata/configmap-yaml/config-yaml-full.golden
@@ -40,7 +40,11 @@ spec:
   - 192.168.0.150-192.168.0.200
   autoAssign: true
   avoidBuggyIPs: true
-status: {}
+status:
+  assignedIPv4: 0
+  assignedIPv6: 0
+  availableIPv4: 0
+  availableIPv6: 0
 ---
 apiVersion: metallb.io/v1beta1
 kind: IPAddressPool
@@ -53,7 +57,11 @@ spec:
   - 192.168.11.0/24
   - 192.168.1.150-192.168.1.200
   avoidBuggyIPs: true
-status: {}
+status:
+  assignedIPv4: 0
+  assignedIPv6: 0
+  availableIPv4: 0
+  availableIPv6: 0
 ---
 apiVersion: metallb.io/v1beta1
 kind: BGPAdvertisement
diff --git a/controller/controller_test.go b/controller/controller_test.go
index cfe11d1b..c04b922e 100644
--- a/controller/controller_test.go
+++ b/controller/controller_test.go
@@ -108,6 +108,8 @@ func (s *testK8S) gotService(in *v1.Service) *v1.Service {
 	return ret
 }
 
+func noopCallback(_ string) {}
+
 func TestControllerMutation(t *testing.T) {
 	testSelector, err := labels.Parse("team=metallb")
 	if err != nil {
@@ -115,7 +117,7 @@ func TestControllerMutation(t *testing.T) {
 	}
 	k := &testK8S{t: t}
 	c := &controller{
-		ips:    allocator.New(),
+		ips:    allocator.New(noopCallback),
 		client: k,
 	}
 	pools := &config.Pools{ByName: map[string]*config.Pool{
@@ -1105,7 +1107,7 @@ func TestControllerMutation(t *testing.T) {
 func TestControllerConfig(t *testing.T) {
 	k := &testK8S{t: t}
 	c := &controller{
-		ips:    allocator.New(),
+		ips:    allocator.New(noopCallback),
 		client: k,
 	}
 
@@ -1202,7 +1204,7 @@ func TestControllerConfig(t *testing.T) {
 func TestDeleteRecyclesIP(t *testing.T) {
 	k := &testK8S{t: t}
 	c := &controller{
-		ips:    allocator.New(),
+		ips:    allocator.New(noopCallback),
 		client: k,
 	}
 
@@ -1272,7 +1274,7 @@ func TestDeleteRecyclesIP(t *testing.T) {
 func TestControllerReassign(t *testing.T) {
 	k := &testK8S{t: t}
 	c := &controller{
-		ips:    allocator.New(),
+		ips:    allocator.New(noopCallback),
 		client: k,
 	}
 
@@ -1368,7 +1370,7 @@ func TestControllerReassign(t *testing.T) {
 func TestControllerDualStackConfig(t *testing.T) {
 	k := &testK8S{t: t}
 	c := &controller{
-		ips:    allocator.New(),
+		ips:    allocator.New(noopCallback),
 		client: k,
 	}
 
diff --git a/e2etest/l2tests/assignment.go b/e2etest/l2tests/assignment.go
index 6c88c5a9..d74bc876 100644
--- a/e2etest/l2tests/assignment.go
+++ b/e2etest/l2tests/assignment.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"time"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 	"go.universe.tf/e2etest/pkg/config"
@@ -21,6 +22,7 @@ import (
 	jigservice "go.universe.tf/e2etest/pkg/jigservice"
 	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
 	clientset "k8s.io/client-go/kubernetes"
 	admissionapi "k8s.io/pod-security-admission/api"
 	"k8s.io/utils/ptr"
@@ -949,13 +951,12 @@ var _ = ginkgo.Describe("IP Assignment", func() {
 		})
 		ginkgo.It("When current dualstack pool becomes single-stack, svc should pick another dualstack pool if possible", func() {
 			pool1 := metallbv1beta1.IPAddressPool{
-				ObjectMeta: metav1.ObjectMeta{Name: "test-ns-dualstack-pool"},
+				ObjectMeta: metav1.ObjectMeta{Name: "test-ns-dualstack-pool-to-edit"},
 				Spec: metallbv1beta1.IPAddressPoolSpec{
 					Addresses: []string{
 						v4PoolAddresses,
 						v6PoolAddresses,
 					},
-					AllocateTo: &metallbv1beta1.ServiceAllocation{Namespaces: []string{testNamespace}},
 				},
 			}
 			resources := config.Resources{
@@ -985,13 +986,27 @@ var _ = ginkgo.Describe("IP Assignment", func() {
 			err = config.ValidateIPInRange([]metallbv1beta1.IPAddressPool{pool1}, jigservice.GetIngressPoint(
 				&svc1.Status.LoadBalancer.Ingress[1]))
 			Expect(err).NotTo(HaveOccurred())
-			ginkgo.By("Updating current pool to have an additional dualstack pool")
+			ginkgo.By("Adding a dualstack pool")
 			resources = config.Resources{
 				Pools: []metallbv1beta1.IPAddressPool{pool1, poolDual},
 			}
 			err = ConfigUpdater.Update(resources)
 			Expect(err).NotTo(HaveOccurred())
-			ginkgo.By("Updating current pool to exclude ipv4 address")
+			ginkgo.By("Verifying that the dualstack pool was loaded")
+			Eventually(func() error {
+				pool := metallbv1beta1.IPAddressPool{}
+				err := ConfigUpdater.Client().Get(context.TODO(), types.NamespacedName{Name: poolDual.Name, Namespace: ConfigUpdater.Namespace()}, &pool)
+				if err != nil {
+					return err
+				}
+
+				if pool.Status.AvailableIPv6 == 0 {
+					return fmt.Errorf("pool %s was not loaded, status is: %v", poolDual.Name, pool.Status)
+				}
+
+				return nil
+			}, 30*time.Second, 1*time.Second).Should(Not(HaveOccurred()))
+			ginkgo.By("Updating pool1 to exclude ipv4 address")
 			pool1.Spec.Addresses = []string{v6PoolAddresses}
 			resources = config.Resources{
 				Pools: []metallbv1beta1.IPAddressPool{pool1, poolDual},
@@ -1002,7 +1017,7 @@ var _ = ginkgo.Describe("IP Assignment", func() {
 			Eventually(func() []string {
 				newIps := getServiceIps(cs, svc1.Namespace, svc1.Name)
 				return newIps
-			}, 5*time.Minute, 1*time.Second).Should(And(
+			}, 30*time.Second, 1*time.Second).Should(And(
 				HaveLen(2),
 				Not(Equal(originallyAssignedIps)),
 			))
@@ -1015,6 +1030,102 @@ var _ = ginkgo.Describe("IP Assignment", func() {
 			Expect(err).NotTo(HaveOccurred())
 		})
 	})
+
+	ginkgo.Context("Pool Status", func() {
+		ginkgo.It("DUALSTACK", func() {
+			testPool := metallbv1beta1.IPAddressPool{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "status-pool",
+					Namespace: metallb.Namespace,
+				},
+				Spec: metallbv1beta1.IPAddressPoolSpec{
+					Addresses: []string{
+						"192.168.10.0/30",
+						"fc00:f853:0ccd:e799::/126",
+					},
+				},
+			}
+			validateStatus := func(expected metallbv1beta1.IPAddressPoolStatus) {
+				Eventually(func() error {
+					p := metallbv1beta1.IPAddressPool{}
+					err := ConfigUpdater.Client().Get(context.Background(), types.NamespacedName{Name: testPool.Name, Namespace: testPool.Namespace}, &p)
+					if err != nil {
+						return err
+					}
+
+					if !cmp.Equal(p.Status, expected) {
+						return fmt.Errorf("pool does not have the expected status (-want +got)\n %v", cmp.Diff(expected, p.Status))
+					}
+
+					return nil
+				}, 30*time.Second, time.Second).ShouldNot(HaveOccurred())
+			}
+
+			ginkgo.By("Creating a Dualstack pool the status should be populated")
+			resources := config.Resources{
+				Pools: []metallbv1beta1.IPAddressPool{testPool},
+			}
+			err := ConfigUpdater.Update(resources)
+			Expect(err).ToNot(HaveOccurred())
+
+			expectedStatus := metallbv1beta1.IPAddressPoolStatus{
+				AvailableIPv4: 4,
+				AvailableIPv6: 4,
+				AssignedIPv4:  0,
+				AssignedIPv6:  0,
+			}
+			validateStatus(expectedStatus)
+
+			ginkgo.By("Creating a service the pool status should be updated")
+			svc1, _ := service.CreateWithBackend(cs, testNamespace, "status-svc", service.DualStack)
+
+			expectedStatus = metallbv1beta1.IPAddressPoolStatus{
+				AvailableIPv4: 3,
+				AvailableIPv6: 3,
+				AssignedIPv4:  1,
+				AssignedIPv6:  1,
+			}
+			validateStatus(expectedStatus)
+
+			ginkgo.By("Expanding the pool's addresses the status should be updated")
+			testPool.Spec.Addresses = []string{"192.168.10.0/29", "fc00:f853:0ccd:e799::/125"}
+			resources = config.Resources{
+				Pools: []metallbv1beta1.IPAddressPool{testPool},
+			}
+			err = ConfigUpdater.Update(resources)
+			Expect(err).ToNot(HaveOccurred())
+			expectedStatus = metallbv1beta1.IPAddressPoolStatus{
+				AvailableIPv4: 7,
+				AvailableIPv6: 7,
+				AssignedIPv4:  1,
+				AssignedIPv6:  1,
+			}
+			validateStatus(expectedStatus)
+
+			ginkgo.By("Deleting the service the status should be updated")
+			service.Delete(cs, svc1)
+			expectedStatus = metallbv1beta1.IPAddressPoolStatus{
+				AvailableIPv4: 8,
+				AvailableIPv6: 8,
+				AssignedIPv4:  0,
+				AssignedIPv6:  0,
+			}
+			validateStatus(expectedStatus)
+
+			ginkgo.By("Manually updating the status it should be reverted")
+			err = ConfigUpdater.Client().Get(context.Background(), types.NamespacedName{Name: testPool.Name, Namespace: testPool.Namespace}, &testPool)
+			Expect(err).ToNot(HaveOccurred())
+			testPool.Status = metallbv1beta1.IPAddressPoolStatus{
+				AvailableIPv4: 5,
+				AvailableIPv6: 5,
+				AssignedIPv4:  5,
+				AssignedIPv6:  5,
+			}
+			err = ConfigUpdater.Client().Status().Update(context.TODO(), &testPool)
+			Expect(err).ToNot(HaveOccurred())
+			validateStatus(expectedStatus)
+		})
+	})
 })
 
 // updateServiceIPFamilyPolicy updates the ipFamilyPolicy of the given svc with the given policy.
diff --git a/e2etest/pkg/config/update.go b/e2etest/pkg/config/update.go
index b44380a0..64244cfd 100644
--- a/e2etest/pkg/config/update.go
+++ b/e2etest/pkg/config/update.go
@@ -126,7 +126,7 @@ func (o beta1Updater) Update(r Resources) error {
 	// Iterating over the map will return the items in a random order.
 	for i, obj := range objects {
 		obj.SetNamespace(o.namespace)
-		_, err := controllerutil.CreateOrUpdate(context.Background(), o.cli, obj, func() error {
+		_, err := controllerutil.CreateOrPatch(context.Background(), o.cli, obj, func() error {
 			// the mutate function is expected to change the object when updating.
 			// we always override with the old version, and we change only the spec part.
 			switch toChange := obj.(type) {
diff --git a/e2etest/webhookstests/webhooks.go b/e2etest/webhookstests/webhooks.go
index 166788c3..3e391973 100644
--- a/e2etest/webhookstests/webhooks.go
+++ b/e2etest/webhookstests/webhooks.go
@@ -21,6 +21,7 @@ package webhookstests
 import (
 	"context"
 	"fmt"
+	"net"
 
 	"go.universe.tf/e2etest/pkg/config"
 	"go.universe.tf/e2etest/pkg/k8s"
@@ -30,9 +31,12 @@ import (
 	. "github.com/onsi/gomega"
 	"github.com/openshift-kni/k8sreporter"
 
+	"go.universe.tf/e2etest/pkg/ipfamily"
+	"go.universe.tf/e2etest/pkg/k8sclient"
 	metallbv1beta1 "go.universe.tf/metallb/api/v1beta1"
 	metallbv1beta2 "go.universe.tf/metallb/api/v1beta2"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	clientset "k8s.io/client-go/kubernetes"
 	"k8s.io/utils/ptr"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
@@ -44,7 +48,10 @@ var (
 )
 
 var _ = ginkgo.Describe("Webhooks", func() {
+	var cs clientset.Interface
 	ginkgo.BeforeEach(func() {
+		cs = k8sclient.New()
+
 		ginkgo.By("Clearing any previous configuration")
 		err := ConfigUpdater.Clean()
 		Expect(err).NotTo(HaveOccurred())
@@ -106,6 +113,45 @@ var _ = ginkgo.Describe("Webhooks", func() {
 			Expect(err).To(HaveOccurred())
 			Expect(err.Error()).To(ContainSubstring("overlaps with already defined CIDR"))
 		})
+
+		ginkgo.DescribeTable("IPAddressPool with overlapping addresses of the nodes",
+			func(ipFamily ipfamily.Family) {
+				nodes, err := cs.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
+				Expect(err).NotTo(HaveOccurred())
+				nodeIps, err := k8s.NodeIPsForFamily(nodes.Items, ipFamily, "")
+				Expect(err).NotTo(HaveOccurred())
+				Expect(len(nodeIps)).To(BeNumerically(">", 0), "empty node ips list")
+				nodeIP := net.ParseIP(nodeIps[0])
+				cidr := &net.IPNet{
+					IP:   nodeIP,
+					Mask: net.CIDRMask(32, 32),
+				}
+				if ipFamily == ipfamily.IPv6 {
+					cidr.Mask = net.CIDRMask(128, 128)
+				}
+
+				ginkgo.By("Creating IPAddressPool")
+				var nodeCIDRs []string
+				nodeCIDRs = append(nodeCIDRs, cidr.String())
+				resources := config.Resources{
+					Pools: []metallbv1beta1.IPAddressPool{
+						{
+							ObjectMeta: metav1.ObjectMeta{
+								Name: "webhooks-test3",
+							},
+							Spec: metallbv1beta1.IPAddressPoolSpec{
+								Addresses: nodeCIDRs,
+							},
+						},
+					},
+				}
+				err = ConfigUpdater.Update(resources)
+				Expect(err).To(HaveOccurred())
+				Expect(err.Error()).To(ContainSubstring("contains nodeIp"))
+			},
+			ginkgo.Entry("IPV4", ipfamily.IPv4),
+			ginkgo.Entry("IPV6", ipfamily.IPv6),
+		)
 	})
 
 	ginkgo.Context("for BGPAdvertisement", func() {
diff --git a/internal/allocator/allocator_test.go b/internal/allocator/allocator_test.go
index d97d4b4a..35e2e0ac 100644
--- a/internal/allocator/allocator_test.go
+++ b/internal/allocator/allocator_test.go
@@ -3,6 +3,7 @@
 package allocator
 
 import (
+	"fmt"
 	"math"
 	"net"
 	"reflect"
@@ -16,6 +17,7 @@ import (
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/util/sets"
 
+	"github.com/google/go-cmp/cmp"
 	ptu "github.com/prometheus/client_golang/prometheus/testutil"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/utils/ptr"
@@ -76,8 +78,10 @@ func selector(s string) labels.Selector {
 	return ret
 }
 
+func noopCallback(_ string) {}
+
 func TestAssignment(t *testing.T) {
-	alloc := New()
+	alloc := New(noopCallback)
 	alloc.SetPools(&config.Pools{ByName: map[string]*config.Pool{
 		"test": {
 			Name:       "test",
@@ -682,7 +686,7 @@ func TestAssignment(t *testing.T) {
 }
 
 func TestPoolAllocation(t *testing.T) {
-	alloc := New()
+	alloc := New(noopCallback)
 	// Majority of this test only allocates from the "test" pool,
 	// so it will run out of IPs quickly even though there are
 	// tons available in other pools.
@@ -1245,7 +1249,7 @@ func TestPoolAllocation(t *testing.T) {
 }
 
 func TestAllocation(t *testing.T) {
-	alloc := New()
+	alloc := New(noopCallback)
 	alloc.SetPools(&config.Pools{ByName: map[string]*config.Pool{
 		"test1": {
 			Name:       "test1",
@@ -1616,7 +1620,7 @@ func TestAllocation(t *testing.T) {
 }
 
 func TestBuggyIPs(t *testing.T) {
-	alloc := New()
+	alloc := New(noopCallback)
 	alloc.SetPools(&config.Pools{ByName: map[string]*config.Pool{
 		"test": {
 			Name:       "test",
@@ -1800,7 +1804,7 @@ func TestConfigReload(t *testing.T) {
 	}
 
 	for _, test := range tests {
-		alloc := New()
+		alloc := New(noopCallback)
 		alloc.SetPools(&config.Pools{ByName: map[string]*config.Pool{
 			"test": {
 				Name:       "test",
@@ -1833,7 +1837,7 @@ func TestConfigReload(t *testing.T) {
 }
 
 func TestAutoAssign(t *testing.T) {
-	alloc := New()
+	alloc := New(noopCallback)
 	alloc.SetPools(&config.Pools{ByName: map[string]*config.Pool{
 		"test1": {
 			Name:       "test1",
@@ -2105,7 +2109,9 @@ func TestPoolCount(t *testing.T) {
 }
 
 func TestPoolMetrics(t *testing.T) {
-	alloc := New()
+	callbackCounter := 0
+	callback := func(_ string) { callbackCounter++ }
+	alloc := New(callback)
 	alloc.SetPools(&config.Pools{ByName: map[string]*config.Pool{
 		"test": {
 			Name:       "test",
@@ -2209,14 +2215,32 @@ func TestPoolMetrics(t *testing.T) {
 	if int(value) != 8 {
 		t.Errorf("stats.poolCapacity invalid %f. Expected 8", value)
 	}
+	counters := alloc.CountersForPool("test")
+	err := validateCounters(counters, 4, 4, 0, 0)
+	if err != nil {
+		t.Error(err)
+	}
+	expectedCallbackCounter := 1
+	if callbackCounter != expectedCallbackCounter {
+		t.Errorf("callbackCounter invalid %d. Expected %d", callbackCounter, expectedCallbackCounter)
+	}
 
 	for _, test := range tests {
+		expectedCallbackCounter++
 		if len(test.ips) == 0 {
 			alloc.Unassign(test.svcKey)
 			value := ptu.ToFloat64(stats.poolActive.WithLabelValues("test"))
 			if value != test.ipsInUse {
 				t.Errorf("%v; in-use %v. Expected %v", test.desc, value, test.ipsInUse)
 			}
+			counters = alloc.CountersForPool("test")
+			err = validateCounters(counters, 4-int64(test.ipsInUse), 4, int64(test.ipsInUse), 0)
+			if err != nil {
+				t.Error(err)
+			}
+			if callbackCounter != expectedCallbackCounter {
+				t.Errorf("callbackCounter invalid %d. Expected %d", callbackCounter, expectedCallbackCounter)
+			}
 			continue
 		}
 
@@ -2238,6 +2262,29 @@ func TestPoolMetrics(t *testing.T) {
 		if value != test.ipsInUse {
 			t.Errorf("%v; in-use %v. Expected %v", test.desc, value, test.ipsInUse)
 		}
+		counters = alloc.CountersForPool("test")
+		err = validateCounters(counters, 4-int64(test.ipsInUse), 4, int64(test.ipsInUse), 0)
+		if err != nil {
+			t.Error(err)
+		}
+		if callbackCounter != expectedCallbackCounter {
+			t.Errorf("callbackCounter invalid %d. Expected %d", callbackCounter, expectedCallbackCounter)
+		}
+	}
+	ips := []net.IP{net.ParseIP("1.2.3.4")}
+	err = alloc.Assign("s1", svc, ips, []Port{}, "", "")
+	if err != nil {
+		t.Errorf("assign failed: %v", err)
+	}
+	alloc.SetPools(&config.Pools{ByName: map[string]*config.Pool{}})
+	val := ptu.ToFloat64(stats.poolActive.WithLabelValues("test")) // we actually expect the value to not be there, but since we can't access it this recreates it with 0
+	if val != 0 {
+		t.Errorf("expected value to be 0, got %v", val)
+	}
+	counters = alloc.CountersForPool("test")
+	err = validateCounters(counters, 0, 0, 0, 0)
+	if err != nil {
+		t.Error(err)
 	}
 }
 
@@ -2296,3 +2343,17 @@ func compareIPs(ips1, ips2 []string) bool {
 	}
 	return true
 }
+
+//nolint:unparam
+func validateCounters(c PoolCounters, avIPv4, avIPv6, asIPv4, asIPv6 int64) error {
+	n := PoolCounters{
+		AvailableIPv4: avIPv4,
+		AvailableIPv6: avIPv6,
+		AssignedIPv4:  asIPv4,
+		AssignedIPv6:  asIPv6,
+	}
+	if !cmp.Equal(c, n) {
+		return fmt.Errorf("unexpected pool counters (-want +got):\n%s", cmp.Diff(n, c))
+	}
+	return nil
+}
diff --git a/internal/k8s/controllers/reconciliation_test.go b/internal/k8s/controllers/reconciliation_test.go
index de717b04..1d846066 100644
--- a/internal/k8s/controllers/reconciliation_test.go
+++ b/internal/k8s/controllers/reconciliation_test.go
@@ -34,6 +34,7 @@ import (
 
 	v1beta1 "go.universe.tf/metallb/api/v1beta1"
 	v1beta2 "go.universe.tf/metallb/api/v1beta2"
+	"go.universe.tf/metallb/internal/allocator"
 	frrk8s "go.universe.tf/metallb/internal/bgp/frrk8s"
 	"go.universe.tf/metallb/internal/config"
 	"go.universe.tf/metallb/internal/layer2"
@@ -74,6 +75,10 @@ var (
 	bgpStatusReconcileChan = make(chan event.GenericEvent)
 	bgpAdvs                = map[string]sets.Set[string]{}
 	bgpAdvsMutex           = sync.Mutex{}
+
+	poolStatusReconcileChan = make(chan event.GenericEvent)
+	poolCounters            = map[string]allocator.PoolCounters{}
+	poolCountersMutex       sync.Mutex
 )
 
 const (
@@ -236,6 +241,18 @@ var _ = BeforeSuite(func() {
 	err = bgpStatusReconciler.SetupWithManager(k8sManager)
 	Expect(err).ToNot(HaveOccurred())
 
+	poolStatusReconciler := &PoolStatusReconciler{
+		Client: k8sManager.GetClient(),
+		Logger: log.NewNopLogger(),
+		CountersFetcher: func(s string) allocator.PoolCounters {
+			poolCountersMutex.Lock()
+			defer poolCountersMutex.Unlock()
+			return poolCounters[s]
+		},
+		ReconcileChan: poolStatusReconcileChan,
+	}
+	err = poolStatusReconciler.SetupWithManager(k8sManager)
+
 	go func() {
 		defer func() { mgrDone.Store(true) }()
 		defer GinkgoRecover()
@@ -732,3 +749,87 @@ var _ = Describe("BGP Status Controller", func() {
 		})
 	})
 })
+
+var _ = Describe("PoolStatus Controller", func() {
+	Context("SetupWithManager", func() {
+		testPoolName := "test"
+		It("Should Reconcile correctly", func() {
+			pool := v1beta1.IPAddressPool{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      testPoolName,
+					Namespace: testNamespace,
+				},
+				Spec: v1beta1.IPAddressPoolSpec{
+					Addresses: []string{
+						"1.2.3.4/30",
+						"1000::4/126",
+					},
+				},
+			}
+			validateStatus := func(expected v1beta1.IPAddressPoolStatus) {
+				Eventually(func() error {
+					newPool := v1beta1.IPAddressPool{}
+					err := k8sClient.Get(context.TODO(), client.ObjectKey{Name: pool.Name, Namespace: testNamespace}, &newPool)
+					if err != nil {
+						return err
+					}
+					if !reflect.DeepEqual(newPool.Status, expected) {
+						return fmt.Errorf("pool status does not match, got [%v] expected [%v]", newPool.Status, expected)
+					}
+					return nil
+				}, 5*time.Second, 200*time.Millisecond).ShouldNot(HaveOccurred())
+			}
+
+			poolCountersMutex.Lock()
+			poolCounters[testPoolName] = allocator.PoolCounters{
+				AvailableIPv4: 4,
+				AvailableIPv6: 4,
+				AssignedIPv4:  0,
+				AssignedIPv6:  0,
+			}
+			poolCountersMutex.Unlock()
+			err := k8sClient.Create(ctx, &pool)
+			Expect(err).ToNot(HaveOccurred())
+
+			expectedStatus := v1beta1.IPAddressPoolStatus{
+				AvailableIPv4: 4,
+				AvailableIPv6: 4,
+				AssignedIPv4:  0,
+				AssignedIPv6:  0,
+			}
+			validateStatus(expectedStatus)
+
+			// Generate a status event
+			poolCountersMutex.Lock()
+			poolCounters[testPoolName] = allocator.PoolCounters{
+				AvailableIPv4: 3,
+				AvailableIPv6: 3,
+				AssignedIPv4:  1,
+				AssignedIPv6:  1,
+			}
+			poolCountersMutex.Unlock()
+			poolStatusReconcileChan <- NewPoolStatusEvent(testNamespace, testPoolName)
+
+			expectedStatus = v1beta1.IPAddressPoolStatus{
+				AvailableIPv4: 3,
+				AvailableIPv6: 3,
+				AssignedIPv4:  1,
+				AssignedIPv6:  1,
+			}
+			validateStatus(expectedStatus)
+
+			// Manual updates should be reverted by the controller
+			err = k8sClient.Get(context.TODO(), client.ObjectKey{Name: pool.Name, Namespace: testNamespace}, &pool)
+			Expect(err).To(Not(HaveOccurred()))
+			pool.Status = v1beta1.IPAddressPoolStatus{
+				AvailableIPv4: 0,
+				AvailableIPv6: 0,
+				AssignedIPv4:  1,
+				AssignedIPv6:  1,
+			}
+			err = k8sClient.Status().Update(context.TODO(), &pool)
+			Expect(err).To(Not(HaveOccurred()))
+			validateStatus(expectedStatus)
+		})
+	})
+})
diff --git a/internal/k8s/webhooks/webhookv1beta1/ipaddresspool_webhook_test.go b/internal/k8s/webhooks/webhookv1beta1/ipaddresspool_webhook_test.go
index abc4ad0c..7a7ed80f 100644
--- a/internal/k8s/webhooks/webhookv1beta1/ipaddresspool_webhook_test.go
+++ b/internal/k8s/webhooks/webhookv1beta1/ipaddresspool_webhook_test.go
@@ -8,6 +8,7 @@ import (
 	"github.com/go-kit/log"
 	"github.com/google/go-cmp/cmp"
 	"go.universe.tf/metallb/api/v1beta1"
+	v1core "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
@@ -29,9 +30,14 @@ func TestValidateIPAddressPool(t *testing.T) {
 			},
 		}, nil
 	}
+	toRestoreNodes := getExistingNodes
+	getExistingNodes = func() (*v1core.NodeList, error) {
+		return &v1core.NodeList{}, nil
+	}
 
 	defer func() {
 		getExistingIPAddressPools = toRestoreIPAddressPools
+		getExistingNodes = toRestoreNodes
 	}()
 
 	tests := []struct {