@@ -17,6 +17,7 @@ package controllers
1717
1818import (
1919 "context"
20+ "encoding/json"
2021 "errors"
2122 "fmt"
2223
@@ -26,6 +27,7 @@ import (
2627 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2728 kerrors "k8s.io/apimachinery/pkg/util/errors"
2829 "k8s.io/client-go/tools/events"
30+ clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2"
2931 "sigs.k8s.io/cluster-api/util"
3032 "sigs.k8s.io/cluster-api/util/annotations"
3133 conditions "sigs.k8s.io/cluster-api/util/conditions"
@@ -35,16 +37,28 @@ import (
3537 "sigs.k8s.io/controller-runtime/pkg/client"
3638 "sigs.k8s.io/controller-runtime/pkg/controller"
3739
40+ infrav1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
3841 infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta2"
3942 "sigs.k8s.io/cluster-api-provider-openstack/pkg/cloud/services/compute"
43+ "sigs.k8s.io/cluster-api-provider-openstack/pkg/cloud/services/networking"
4044 "sigs.k8s.io/cluster-api-provider-openstack/pkg/scope"
4145 controllers "sigs.k8s.io/cluster-api-provider-openstack/pkg/utils/controllers"
4246)
4347
44- const imagePropertyForOS = "os_type"
48+ const (
49+ imagePropertyForOS = "os_type"
50+
51+ // annotationAllowedAddressPairs tracks the last-applied allowedAddressPairs per
52+ // OpenStackMachine (stored as JSON). Written as a metadata annotation so we never
53+ // touch the immutable OSM spec, which would trigger the spec-immutability webhook.
54+ annotationAllowedAddressPairs = "infrastructure.cluster.x-k8s.io/osmt-allowed-address-pairs"
55+ )
4556
4657// Set here so we can easily mock it in tests.
47- var newComputeService = compute .NewService
58+ var (
59+ newComputeService = compute .NewService
60+ newNetworkingService = networking .NewService
61+ )
4862
4963// OpenStackMachineTemplateReconciler reconciles a OpenStackMachineTemplate object.
5064// it only updates the .status field to allow auto-scaling.
@@ -58,6 +72,10 @@ type OpenStackMachineTemplateReconciler struct {
5872
5973// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackmachinetemplates,verbs=get;list;watch
6074// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackmachinetemplates/status,verbs=get;update;patch
75+ // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackmachines,verbs=get;list;watch;patch
76+ // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackservers,verbs=get;list;watch
77+ // +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=machinesets,verbs=get;list;watch
78+ // +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=machines,verbs=get;list;watch
6179
6280func (r * OpenStackMachineTemplateReconciler ) Reconcile (ctx context.Context , req ctrl.Request ) (result ctrl.Result , reterr error ) {
6381 log := ctrl .LoggerFrom (ctx )
@@ -142,14 +160,14 @@ func (r *OpenStackMachineTemplateReconciler) Reconcile(ctx context.Context, req
142160 scope := scope .NewWithLogger (clientScope , log )
143161
144162 // Handle non-deleted OpenStackMachineTemplates
145- if err := r .reconcileNormal (ctx , scope , openStackMachineTemplate ); err != nil {
163+ if err := r .reconcileNormal (ctx , scope , cluster . Name , openStackMachineTemplate ); err != nil {
146164 return ctrl.Result {}, err
147165 }
148166 log .V (4 ).Info ("Successfully reconciled OpenStackMachineTemplate" )
149167 return ctrl.Result {}, nil
150168}
151169
152- func (r * OpenStackMachineTemplateReconciler ) reconcileNormal (ctx context.Context , scope * scope.WithLogger , openStackMachineTemplate * infrav1.OpenStackMachineTemplate ) (reterr error ) {
170+ func (r * OpenStackMachineTemplateReconciler ) reconcileNormal (ctx context.Context , scope * scope.WithLogger , clusterName string , openStackMachineTemplate * infrav1.OpenStackMachineTemplate ) (reterr error ) {
153171 log := scope .Logger ()
154172
155173 computeService , err := newComputeService (scope )
@@ -201,10 +219,19 @@ func (r *OpenStackMachineTemplateReconciler) reconcileNormal(ctx context.Context
201219 openStackMachineTemplate .Status .Capacity [corev1 .ResourceStorage ] = * resource .NewQuantity (storageBytes , resource .BinarySI )
202220 }
203221
222+ // reconcileAllowedAddressPairs is called independently of the image/flavor logic so that
223+ // it is never skipped by an early return (e.g. when imageID is not yet resolvable).
224+ if err := r .reconcileAllowedAddressPairs (ctx , scope , clusterName , openStackMachineTemplate ); err != nil {
225+ return err
226+ }
227+
204228 imageID , err := computeService .GetImageID (ctx , r .Client , openStackMachineTemplate .Namespace , openStackMachineTemplate .Spec .Template .Spec .Image )
205229 if err != nil {
206230 return err
207231 }
232+ if imageID == nil {
233+ return nil
234+ }
208235
209236 image , err := computeService .GetImageDetails (* imageID )
210237 if err != nil {
@@ -224,6 +251,144 @@ func (r *OpenStackMachineTemplateReconciler) reconcileNormal(ctx context.Context
224251 return nil
225252}
226253
254+ // reconcileAllowedAddressPairs updates the allowedAddressPairs on existing Neutron ports
255+ // to match what is defined in the OpenStackMachineTemplate.
256+ // Idempotency is tracked via an annotation on each OpenStackMachine so that only a
257+ // metadata-only patch is needed — this avoids touching the immutable OSM spec.
258+ func (r * OpenStackMachineTemplateReconciler ) reconcileAllowedAddressPairs (ctx context.Context , scope * scope.WithLogger , clusterName string , openStackMachineTemplate * infrav1.OpenStackMachineTemplate ) error {
259+ log := scope .Logger ()
260+
261+ if len (openStackMachineTemplate .Spec .Template .Spec .Ports ) == 0 || clusterName == "" {
262+ return nil
263+ }
264+
265+ // Build the desired state as JSON for idempotency comparison.
266+ type portPairs = []infrav1.AddressPair
267+ templatePorts := openStackMachineTemplate .Spec .Template .Spec .Ports
268+ desired := make ([]portPairs , len (templatePorts ))
269+ for i , p := range templatePorts {
270+ desired [i ] = p .AllowedAddressPairs
271+ }
272+ desiredJSON , err := json .Marshal (desired )
273+ if err != nil {
274+ return err
275+ }
276+ desiredStr := string (desiredJSON )
277+
278+ // List MachineSets in the namespace for this cluster.
279+ machineSetList := & clusterv1.MachineSetList {}
280+ if err := r .Client .List (ctx , machineSetList ,
281+ client .InNamespace (openStackMachineTemplate .Namespace ),
282+ client.MatchingLabels {clusterv1 .ClusterNameLabel : clusterName },
283+ ); err != nil {
284+ return err
285+ }
286+
287+ // List Machines in the namespace for this cluster.
288+ machineList := & clusterv1.MachineList {}
289+ if err := r .Client .List (ctx , machineList ,
290+ client .InNamespace (openStackMachineTemplate .Namespace ),
291+ client.MatchingLabels {clusterv1 .ClusterNameLabel : clusterName },
292+ ); err != nil {
293+ return err
294+ }
295+
296+ // Networking service is initialised lazily on first actual port update.
297+ var networkingService * networking.Service
298+
299+ for i := range machineSetList .Items {
300+ ms := & machineSetList .Items [i ]
301+ if ms .Spec .Template .Spec .InfrastructureRef .Name != openStackMachineTemplate .Name {
302+ continue
303+ }
304+
305+ for j := range machineList .Items {
306+ machine := & machineList .Items [j ]
307+ if ! isOwnedByMachineSet (machine , ms ) {
308+ continue
309+ }
310+ infraName := machine .Spec .InfrastructureRef .Name
311+ if infraName == "" {
312+ continue
313+ }
314+
315+ osm := & infrav1.OpenStackMachine {}
316+ if err := r .Client .Get (ctx , client.ObjectKey {
317+ Namespace : openStackMachineTemplate .Namespace ,
318+ Name : infraName ,
319+ }, osm ); err != nil {
320+ if apierrors .IsNotFound (err ) {
321+ continue
322+ }
323+ return err
324+ }
325+
326+ // Skip if annotation already reflects the desired state.
327+ if osm .Annotations [annotationAllowedAddressPairs ] == desiredStr {
328+ continue
329+ }
330+
331+ // Port IDs are stored in the OpenStackServer status (same name as the OSM).
332+ openStackServer := & infrav1alpha1.OpenStackServer {}
333+ if err := r .Client .Get (ctx , client.ObjectKey {
334+ Namespace : openStackMachineTemplate .Namespace ,
335+ Name : infraName ,
336+ }, openStackServer ); err != nil {
337+ if apierrors .IsNotFound (err ) {
338+ continue
339+ }
340+ return err
341+ }
342+
343+ if openStackServer .Status .Resources == nil || len (openStackServer .Status .Resources .Ports ) == 0 {
344+ continue
345+ }
346+
347+ if networkingService == nil {
348+ networkingService , err = newNetworkingService (scope )
349+ if err != nil {
350+ return err
351+ }
352+ }
353+
354+ for portIdx , portStatus := range openStackServer .Status .Resources .Ports {
355+ if portIdx >= len (templatePorts ) {
356+ break
357+ }
358+ pairs := templatePorts [portIdx ].AllowedAddressPairs
359+ log .Info ("Updating allowedAddressPairs on port" , "portID" , portStatus .ID ,
360+ "machine" , osm .Name , "portIndex" , portIdx )
361+ if err := networkingService .UpdateAllowedAddressPairs (portStatus .ID , pairs ); err != nil {
362+ log .Error (err , "Failed to update allowedAddressPairs" , "portID" , portStatus .ID )
363+ return err
364+ }
365+ }
366+
367+ // Record the applied state in an annotation (metadata-only patch).
368+ osmCopy := osm .DeepCopy ()
369+ if osm .Annotations == nil {
370+ osm .Annotations = map [string ]string {}
371+ }
372+ osm .Annotations [annotationAllowedAddressPairs ] = desiredStr
373+ if err := r .Client .Patch (ctx , osm , client .MergeFrom (osmCopy )); err != nil {
374+ return err
375+ }
376+ }
377+ }
378+
379+ return nil
380+ }
381+
382+ // isOwnedByMachineSet returns true if the Machine has an owner reference pointing to ms.
383+ func isOwnedByMachineSet (machine * clusterv1.Machine , ms * clusterv1.MachineSet ) bool {
384+ for _ , ref := range machine .OwnerReferences {
385+ if ref .Kind == "MachineSet" && ref .Name == ms .Name {
386+ return true
387+ }
388+ }
389+ return false
390+ }
391+
227392func (r * OpenStackMachineTemplateReconciler ) SetupWithManager (ctx context.Context , mgr ctrl.Manager , options controller.Options ) error {
228393 log := ctrl .LoggerFrom (ctx )
229394
0 commit comments