Skip to content

Commit fff939b

Browse files
authored
PCP-6608: cluster-api-provider-maas wipes API server FQDN empty IP set persisted to MAAS DNS when CP machine is transiently powered off, hash-cached so no self-recovery (#339)
* PCP-6608: cluster-api-provider-maas wipes API server FQDN: empty IP set persisted to MAAS DNS when CP machine is transiently powered off, hash-cached so no self-recovery * PCP-6608: address PR review comments - Remove empty-IP guard from updateResourceIPs helper; guard belongs in reconcileDNSAttachments where intent is known, not in a shared helper that must support intentional clearing (deprovisioning) - Deduplicate runningIpAddresses before hashing so the annotation always represents the applied IP set, consistent with updateResourceIPs set semantics — prevents spurious re-syncs if duplicate IPs appear - Add IsDriftDetected edge-case tests: duplicate desired IPs and empty strings in desired are correctly handled without false drift signals * PCP-6608: fix stale DNS on last CP deletion; add missing test coverage Fix #2 - last CP deletion leaves stale DNS: Track existingCPCount (CPs without DeletionTimestamp) separately. Preserve DNS only when existingCPCount > 0 (transient power-off flap). When existingCPCount == 0 (all CPs absent or pending deletion), fall through to clear DNS so stale records don't persist after a rolling replacement or scale-down. Tests (#4, #5, #6): - CP with DeletionTimestamp: excluded from existingCPCount, DNS cleared - CP running but no ExternalIP: existingCPCount>0, DNS preserved (covers preferred-subnet mismatch code path) - GetDNSResource error: error propagated to caller - Updated "no CP machines" assertion to reflect new DNS-clear behaviour * Fix go vul check
1 parent 6a7fa43 commit fff939b

6 files changed

Lines changed: 582 additions & 17 deletions

File tree

controllers/maascluster_controller.go

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,18 @@ import (
4848
"github.com/spectrocloud/cluster-api-provider-maas/pkg/maas/lxd"
4949
"github.com/spectrocloud/cluster-api-provider-maas/pkg/maas/scope"
5050
infrautil "github.com/spectrocloud/cluster-api-provider-maas/pkg/util"
51+
"github.com/spectrocloud/maas-client-go/maasclient"
5152
"sigs.k8s.io/controller-runtime/pkg/controller"
5253
)
5354

55+
// dnsServicer is the subset of dns.Service used by reconcileDNSAttachments.
56+
// Extracted as an interface to allow unit testing without a live MAAS endpoint.
57+
type dnsServicer interface {
58+
GetDNSResource() (maasclient.DNSResource, error)
59+
UpdateDNSAttachmentsWithResource(maasclient.DNSResource, []string) (bool, error)
60+
IsDriftDetected(maasclient.DNSResource, []string) bool
61+
}
62+
5463
const lastAppliedAnn = "infrastructure.cluster.x-k8s.io/last-applied-dns-hash"
5564

5665
// MaasClusterReconciler reconciles a MaasCluster object
@@ -155,7 +164,7 @@ func (r *MaasClusterReconciler) reconcileDelete(ctx context.Context, clusterScop
155164
return reconcile.Result{}, nil
156165
}
157166

158-
func (r *MaasClusterReconciler) reconcileDNSAttachments(clusterScope *scope.ClusterScope, dnssvc *dns.Service) error {
167+
func (r *MaasClusterReconciler) reconcileDNSAttachments(clusterScope *scope.ClusterScope, dnssvc dnsServicer) error {
159168

160169
if clusterScope.IsCustomEndpoint() {
161170
return nil
@@ -171,34 +180,74 @@ func (r *MaasClusterReconciler) reconcileDNSAttachments(clusterScope *scope.Clus
171180
return errors.Wrapf(err, "unable to find preferred subnets")
172181
}
173182

174-
// Build desired IPs first (no MAAS call)
183+
// Build desired IPs first (no MAAS call). Track two separate counts:
184+
// existingCPCount — CPs that exist and are NOT being deleted (DeletionTimestamp zero).
185+
// Used to distinguish a transient power-off from a genuine scale-down.
186+
// runningCPCount — CPs that are powered on and running.
187+
// Used to detect preferred-subnet misconfiguration.
188+
var existingCPCount, runningCPCount int
175189
var runningIpAddresses []string
176190
for _, m := range machines {
177191
if !IsControlPlaneMachine(m) {
178192
continue
179193
}
180-
machineIP := getExternalMachineIP(clusterScope.Logger, preferredSubnets, m)
194+
if m.DeletionTimestamp.IsZero() {
195+
existingCPCount++
196+
}
181197
isRunningHealthy := IsRunning(m)
182198
if !m.DeletionTimestamp.IsZero() || !isRunningHealthy {
183199
continue
184200
}
201+
runningCPCount++
202+
machineIP := getExternalMachineIP(clusterScope.Logger, preferredSubnets, m)
185203
if machineIP != "" {
186204
runningIpAddresses = append(runningIpAddresses, machineIP)
187205
}
188206
}
189207

190-
// Early-exit gate using last-applied hash
191-
desiredHash := infrautil.StableHashStringSlice(runningIpAddresses)
192-
if clusterScope.MaasCluster.Annotations != nil && clusterScope.MaasCluster.Annotations[lastAppliedAnn] == desiredHash {
193-
return nil
208+
if len(runningIpAddresses) == 0 {
209+
if existingCPCount > 0 {
210+
// CP objects exist but are transiently unavailable (powered off / IPs not yet
211+
// assigned / filtered by preferred subnets). Preserve existing DNS records so
212+
// the cluster remains reachable during the flap; DNS self-heals when CPs recover.
213+
if runningCPCount > 0 {
214+
clusterScope.Info("CP machines are running but no IPs match preferred subnets; preserving existing DNS records",
215+
"runningCPs", runningCPCount)
216+
} else {
217+
clusterScope.Info("No running CP machines found; preserving existing DNS records",
218+
"existingCPs", existingCPCount)
219+
}
220+
return nil
221+
}
222+
// No CP objects exist or all are pending deletion (scale-down / rolling replacement
223+
// with no new CPs provisioned yet). Fall through to clear DNS records.
224+
clusterScope.Info("All CP machines absent or pending deletion; clearing DNS records")
194225
}
195226

196-
// Only now fetch DNS resource once
227+
// Deduplicate before hashing so the annotation always represents the applied IP *set*.
228+
// updateResourceIPs deduplicates via a set internally; without this step the hash could
229+
// differ between reconciles if duplicate IPs appear/disappear while MAAS state is unchanged,
230+
// causing spurious re-syncs.
231+
runningIpAddresses = dedupStringSlice(runningIpAddresses)
232+
233+
// Fetch DNS resource once — needed for both drift check and potential update.
197234
dnsResource, err := dnssvc.GetDNSResource()
198235
if err != nil {
199236
return errors.Wrap(err, "Unable to get the dns resource")
200237
}
201238

239+
// Early-exit only when the annotation hash matches AND MAAS state agrees with desired IPs.
240+
// Verifying MAAS state catches external drift (e.g. DNS records manually wiped while the
241+
// desired set was unchanged, so the hash never changed).
242+
desiredHash := infrautil.StableHashStringSlice(runningIpAddresses)
243+
if clusterScope.MaasCluster.Annotations != nil && clusterScope.MaasCluster.Annotations[lastAppliedAnn] == desiredHash {
244+
if !dnssvc.IsDriftDetected(dnsResource, runningIpAddresses) {
245+
return nil
246+
}
247+
clusterScope.Info("DNS drift detected: MAAS state diverges from last-applied annotation; forcing re-sync",
248+
"desiredIPs", runningIpAddresses)
249+
}
250+
202251
// Use optimized update with in-resource idempotency
203252
updated, err := dnssvc.UpdateDNSAttachmentsWithResource(dnsResource, runningIpAddresses)
204253
if err != nil {
@@ -211,14 +260,26 @@ func (r *MaasClusterReconciler) reconcileDNSAttachments(clusterScope *scope.Clus
211260
}
212261
clusterScope.MaasCluster.Annotations[lastAppliedAnn] = desiredHash
213262

214-
// Best-effort requeue hint remains optional; we skip computing current vs attached
215263
if updated {
216264
clusterScope.Info("DNS attachments updated; will continue monitoring for changes")
217265
}
218266

219267
return nil
220268
}
221269

270+
// dedupStringSlice returns a new slice with duplicate elements removed, preserving order.
271+
func dedupStringSlice(in []string) []string {
272+
seen := make(map[string]struct{}, len(in))
273+
out := make([]string, 0, len(in))
274+
for _, s := range in {
275+
if _, ok := seen[s]; !ok {
276+
seen[s] = struct{}{}
277+
out = append(out, s)
278+
}
279+
}
280+
return out
281+
}
282+
222283
// IsControlPlaneMachine checks machine is a control plane node.
223284
func IsControlPlaneMachine(m *infrav1beta1.MaasMachine) bool {
224285
_, ok := m.ObjectMeta.Labels[clusterv1.MachineControlPlaneLabel]

0 commit comments

Comments
 (0)