From 440695ab41e8fa8f5f4f2b847504bc5eda6641b7 Mon Sep 17 00:00:00 2001 From: Amit Sahastrabuddhe Date: Fri, 8 May 2026 11:18:47 +0530 Subject: [PATCH 1/5] 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 --- controllers/maascluster_controller.go | 35 +++++++++++++++++++++------ pkg/maas/dns/dns.go | 23 ++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/controllers/maascluster_controller.go b/controllers/maascluster_controller.go index 1f0db1a28..40ffc7d9e 100644 --- a/controllers/maascluster_controller.go +++ b/controllers/maascluster_controller.go @@ -171,34 +171,56 @@ func (r *MaasClusterReconciler) reconcileDNSAttachments(clusterScope *scope.Clus return errors.Wrapf(err, "unable to find preferred subnets") } - // Build desired IPs first (no MAAS call) + // Build desired IPs first (no MAAS call). Track running CP count separately so we can + // distinguish "machines powered off" from "machines running but IPs filtered by subnets". + var runningCPCount int var runningIpAddresses []string for _, m := range machines { if !IsControlPlaneMachine(m) { continue } - machineIP := getExternalMachineIP(clusterScope.Logger, preferredSubnets, m) isRunningHealthy := IsRunning(m) if !m.DeletionTimestamp.IsZero() || !isRunningHealthy { continue } + runningCPCount++ + machineIP := getExternalMachineIP(clusterScope.Logger, preferredSubnets, m) if machineIP != "" { runningIpAddresses = append(runningIpAddresses, machineIP) } } - // Early-exit gate using last-applied hash - desiredHash := infrautil.StableHashStringSlice(runningIpAddresses) - if clusterScope.MaasCluster.Annotations != nil && clusterScope.MaasCluster.Annotations[lastAppliedAnn] == desiredHash { + // Never wipe DNS when all CP machines are transiently unavailable (e.g. powered off during + // a flap). An empty desired set would clear the MAAS A records and the hash would be cached + // as SHA-256(""), preventing self-recovery until the controller pod itself is reachable again. + if len(runningIpAddresses) == 0 { + if runningCPCount > 0 { + clusterScope.Info("CP machines are running but no IPs match preferred subnets; preserving existing DNS records", + "runningCPs", runningCPCount) + } else { + clusterScope.Info("No running CP machines found; preserving existing DNS records") + } return nil } - // Only now fetch DNS resource once + // Fetch DNS resource once — needed for both drift check and potential update. dnsResource, err := dnssvc.GetDNSResource() if err != nil { return errors.Wrap(err, "Unable to get the dns resource") } + // Early-exit only when the annotation hash matches AND MAAS state agrees with desired IPs. + // Verifying MAAS state catches external drift (e.g. DNS records manually wiped while the + // desired set was unchanged, so the hash never changed). + desiredHash := infrautil.StableHashStringSlice(runningIpAddresses) + if clusterScope.MaasCluster.Annotations != nil && clusterScope.MaasCluster.Annotations[lastAppliedAnn] == desiredHash { + if !dnssvc.IsDriftDetected(dnsResource, runningIpAddresses) { + return nil + } + clusterScope.Info("DNS drift detected: MAAS state diverges from last-applied annotation; forcing re-sync", + "desiredIPs", runningIpAddresses) + } + // Use optimized update with in-resource idempotency updated, err := dnssvc.UpdateDNSAttachmentsWithResource(dnsResource, runningIpAddresses) if err != nil { @@ -211,7 +233,6 @@ func (r *MaasClusterReconciler) reconcileDNSAttachments(clusterScope *scope.Clus } clusterScope.MaasCluster.Annotations[lastAppliedAnn] = desiredHash - // Best-effort requeue hint remains optional; we skip computing current vs attached if updated { clusterScope.Info("DNS attachments updated; will continue monitoring for changes") } diff --git a/pkg/maas/dns/dns.go b/pkg/maas/dns/dns.go index e1e8a8d45..31186e47d 100644 --- a/pkg/maas/dns/dns.go +++ b/pkg/maas/dns/dns.go @@ -59,6 +59,24 @@ func (s *Service) ReconcileDNS() error { return nil } +// IsDriftDetected returns true if the MAAS DNS resource's current IP set differs from desiredIPs. +// Used to catch external drift (e.g. manual wipe) even when the annotation hash has not changed. +func (s *Service) IsDriftDetected(dnsResource maasclient.DNSResource, desiredIPs []string) bool { + desired := sets.NewString() + for _, ip := range desiredIPs { + if ip != "" { + desired.Insert(ip) + } + } + current := sets.NewString() + for _, addr := range dnsResource.IPAddresses() { + if a := addr.IP().String(); a != "" { + current.Insert(a) + } + } + return !desired.Equal(current) +} + // UpdateDNSAttachmentsWithResource updates DNS attachments using a pre-fetched DNS resource, // avoiding additional GET API calls. Returns true if an update was performed. func (s *Service) UpdateDNSAttachmentsWithResource(dnsResource maasclient.DNSResource, IPs []string) (bool, error) { @@ -83,6 +101,11 @@ func (s *Service) updateResourceIPs(dnsResource maasclient.DNSResource, IPs []st } } + // Refuse to wipe all A records; callers must guard before reaching here. + if desired.Len() == 0 { + return false, errors.New("refusing to PUT empty IP set to MAAS DNS resource") + } + // Build current set from resource current := sets.NewString() for _, addr := range dnsResource.IPAddresses() { From 17f76395fa739ef754c8a10e9ca70463534f9d73 Mon Sep 17 00:00:00 2001 From: Amit Sahastrabuddhe Date: Fri, 8 May 2026 13:02:28 +0530 Subject: [PATCH 2/5] PCP-6608: address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- controllers/maascluster_controller.go | 30 +- .../maascluster_controller_dns_test.go | 327 ++++++++++++++++++ pkg/maas/dns/dns.go | 5 - pkg/maas/dns/dns_test.go | 104 +++++- 4 files changed, 458 insertions(+), 8 deletions(-) create mode 100644 controllers/maascluster_controller_dns_test.go diff --git a/controllers/maascluster_controller.go b/controllers/maascluster_controller.go index 40ffc7d9e..daf36ee60 100644 --- a/controllers/maascluster_controller.go +++ b/controllers/maascluster_controller.go @@ -48,9 +48,18 @@ import ( "github.com/spectrocloud/cluster-api-provider-maas/pkg/maas/lxd" "github.com/spectrocloud/cluster-api-provider-maas/pkg/maas/scope" infrautil "github.com/spectrocloud/cluster-api-provider-maas/pkg/util" + "github.com/spectrocloud/maas-client-go/maasclient" "sigs.k8s.io/controller-runtime/pkg/controller" ) +// dnsServicer is the subset of dns.Service used by reconcileDNSAttachments. +// Extracted as an interface to allow unit testing without a live MAAS endpoint. +type dnsServicer interface { + GetDNSResource() (maasclient.DNSResource, error) + UpdateDNSAttachmentsWithResource(maasclient.DNSResource, []string) (bool, error) + IsDriftDetected(maasclient.DNSResource, []string) bool +} + const lastAppliedAnn = "infrastructure.cluster.x-k8s.io/last-applied-dns-hash" // MaasClusterReconciler reconciles a MaasCluster object @@ -155,7 +164,7 @@ func (r *MaasClusterReconciler) reconcileDelete(ctx context.Context, clusterScop return reconcile.Result{}, nil } -func (r *MaasClusterReconciler) reconcileDNSAttachments(clusterScope *scope.ClusterScope, dnssvc *dns.Service) error { +func (r *MaasClusterReconciler) reconcileDNSAttachments(clusterScope *scope.ClusterScope, dnssvc dnsServicer) error { if clusterScope.IsCustomEndpoint() { return nil @@ -203,6 +212,12 @@ func (r *MaasClusterReconciler) reconcileDNSAttachments(clusterScope *scope.Clus return nil } + // Deduplicate before hashing so the annotation always represents the applied IP *set*. + // updateResourceIPs deduplicates via a set internally; without this step the hash could + // differ between reconciles if duplicate IPs appear/disappear while MAAS state is unchanged, + // causing spurious re-syncs. + runningIpAddresses = dedupStringSlice(runningIpAddresses) + // Fetch DNS resource once — needed for both drift check and potential update. dnsResource, err := dnssvc.GetDNSResource() if err != nil { @@ -240,6 +255,19 @@ func (r *MaasClusterReconciler) reconcileDNSAttachments(clusterScope *scope.Clus return nil } +// dedupStringSlice returns a new slice with duplicate elements removed, preserving order. +func dedupStringSlice(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, s := range in { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + out = append(out, s) + } + } + return out +} + // IsControlPlaneMachine checks machine is a control plane node. func IsControlPlaneMachine(m *infrav1beta1.MaasMachine) bool { _, ok := m.ObjectMeta.Labels[clusterv1.MachineControlPlaneLabel] diff --git a/controllers/maascluster_controller_dns_test.go b/controllers/maascluster_controller_dns_test.go new file mode 100644 index 000000000..f22d63de8 --- /dev/null +++ b/controllers/maascluster_controller_dns_test.go @@ -0,0 +1,327 @@ +package controllers + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/klog/v2/klogr" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + k8sscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + infrav1beta1 "github.com/spectrocloud/cluster-api-provider-maas/api/v1beta1" + "github.com/spectrocloud/cluster-api-provider-maas/pkg/maas/scope" + infrautil "github.com/spectrocloud/cluster-api-provider-maas/pkg/util" + "github.com/spectrocloud/maas-client-go/maasclient" +) + +// mockDNSServicer implements dnsServicer for unit tests without a live MAAS endpoint. +type mockDNSServicer struct { + dnsResource maasclient.DNSResource + getDNSResourceErr error + updateCalled bool + updateIPs []string + isDriftResult bool + isDriftCalled bool +} + +func (m *mockDNSServicer) GetDNSResource() (maasclient.DNSResource, error) { + return m.dnsResource, m.getDNSResourceErr +} + +func (m *mockDNSServicer) UpdateDNSAttachmentsWithResource(_ maasclient.DNSResource, ips []string) (bool, error) { + m.updateCalled = true + m.updateIPs = ips + return true, nil +} + +func (m *mockDNSServicer) IsDriftDetected(_ maasclient.DNSResource, _ []string) bool { + m.isDriftCalled = true + return m.isDriftResult +} + +func machineStatePtr(s infrav1beta1.MachineState) *infrav1beta1.MachineState { return &s } + +func makeCPMachine(name, ns, clusterName string, powered bool, state infrav1beta1.MachineState, ip string) *infrav1beta1.MaasMachine { + m := &infrav1beta1.MaasMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + Labels: map[string]string{ + clusterv1.ClusterNameLabel: clusterName, + clusterv1.MachineControlPlaneLabel: "", + }, + }, + Status: infrav1beta1.MaasMachineStatus{ + MachinePowered: powered, + MachineState: machineStatePtr(state), + }, + } + if ip != "" { + m.Status.Addresses = []clusterv1.MachineAddress{ + {Type: clusterv1.MachineExternalIP, Address: ip}, + } + } + return m +} + +func newTestClusterScope(t *testing.T, maasCluster *infrav1beta1.MaasCluster, machines ...client.Object) *scope.ClusterScope { + t.Helper() + scheme := runtime.NewScheme() + _ = infrav1beta1.AddToScheme(scheme) + _ = clusterv1.AddToScheme(scheme) + _ = k8sscheme.AddToScheme(scheme) // needed for ConfigMap (GetPreferredSubnets) + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(machines...).Build() + + cs, err := scope.NewClusterScope(scope.ClusterScopeParams{ + Client: fakeClient, + Logger: klogr.New(), + Cluster: &clusterv1.Cluster{ObjectMeta: metav1.ObjectMeta{Name: "test-cluster", Namespace: "test-ns"}}, + MaasCluster: maasCluster, + }) + if err != nil { + t.Fatalf("NewClusterScope: %v", err) + } + return cs +} + +func defaultMaasCluster() *infrav1beta1.MaasCluster { + return &infrav1beta1.MaasCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cluster", Namespace: "test-ns"}, + Spec: infrav1beta1.MaasClusterSpec{DNSDomain: "maas.test"}, + Status: infrav1beta1.MaasClusterStatus{ + Network: infrav1beta1.Network{DNSName: "test-cluster-abc.maas.test"}, + }, + } +} + +// --------------------------------------------------------------------------- +// IsRunning +// --------------------------------------------------------------------------- + +func TestIsRunning(t *testing.T) { + tests := []struct { + name string + powered bool + state infrav1beta1.MachineState + expected bool + }{ + {"deployed and powered on", true, infrav1beta1.MachineStateDeployed, true}, + {"deployed but powered off (the bug case)", false, infrav1beta1.MachineStateDeployed, false}, + {"deploying and powered on", true, infrav1beta1.MachineStateDeploying, true}, + {"deploying but powered off", false, infrav1beta1.MachineStateDeploying, false}, + {"allocated (not in RunningStates)", true, infrav1beta1.MachineStateAllocated, false}, + {"ready (not in RunningStates)", true, infrav1beta1.MachineStateReady, false}, + {"releasing (not in RunningStates)", true, infrav1beta1.MachineStateReleasing, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewGomegaWithT(t) + m := &infrav1beta1.MaasMachine{ + Status: infrav1beta1.MaasMachineStatus{ + MachinePowered: tc.powered, + MachineState: machineStatePtr(tc.state), + }, + } + g.Expect(IsRunning(m)).To(Equal(tc.expected)) + }) + } +} + +func TestIsRunning_NilState(t *testing.T) { + g := NewGomegaWithT(t) + m := &infrav1beta1.MaasMachine{ + Status: infrav1beta1.MaasMachineStatus{MachinePowered: true, MachineState: nil}, + } + g.Expect(IsRunning(m)).To(BeFalse()) +} + +// --------------------------------------------------------------------------- +// IsControlPlaneMachine +// --------------------------------------------------------------------------- + +func TestIsControlPlaneMachine(t *testing.T) { + g := NewGomegaWithT(t) + + cp := &infrav1beta1.MaasMachine{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{clusterv1.MachineControlPlaneLabel: ""}}, + } + worker := &infrav1beta1.MaasMachine{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"other": "label"}}, + } + + g.Expect(IsControlPlaneMachine(cp)).To(BeTrue()) + g.Expect(IsControlPlaneMachine(worker)).To(BeFalse()) +} + +// --------------------------------------------------------------------------- +// getExternalMachineIP +// --------------------------------------------------------------------------- + +func TestGetExternalMachineIP(t *testing.T) { + log := klogr.New() + + tests := []struct { + name string + preferredSubnets []string + addresses []clusterv1.MachineAddress + expectedIP string + }{ + { + name: "no subnet filter, has ExternalIP", + preferredSubnets: nil, + addresses: []clusterv1.MachineAddress{ + {Type: clusterv1.MachineExternalIP, Address: "10.0.0.1"}, + }, + expectedIP: "10.0.0.1", + }, + { + name: "no subnet filter, no ExternalIP (only InternalIP)", + preferredSubnets: nil, + addresses: []clusterv1.MachineAddress{ + {Type: clusterv1.MachineInternalIP, Address: "10.0.0.1"}, + }, + expectedIP: "", + }, + { + name: "subnet filter matches IP", + preferredSubnets: []string{"10.0.0.0/24"}, + addresses: []clusterv1.MachineAddress{ + {Type: clusterv1.MachineExternalIP, Address: "10.0.0.5"}, + }, + expectedIP: "10.0.0.5", + }, + { + name: "subnet filter does not match IP", + preferredSubnets: []string{"192.168.1.0/24"}, + addresses: []clusterv1.MachineAddress{ + {Type: clusterv1.MachineExternalIP, Address: "10.0.0.5"}, + }, + expectedIP: "", + }, + { + name: "no addresses", + preferredSubnets: nil, + addresses: nil, + expectedIP: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewGomegaWithT(t) + m := &infrav1beta1.MaasMachine{Status: infrav1beta1.MaasMachineStatus{Addresses: tc.addresses}} + g.Expect(getExternalMachineIP(log, tc.preferredSubnets, m)).To(Equal(tc.expectedIP)) + }) + } +} + +// --------------------------------------------------------------------------- +// reconcileDNSAttachments +// --------------------------------------------------------------------------- + +func TestReconcileDNSAttachments(t *testing.T) { + r := &MaasClusterReconciler{Log: klogr.New()} + + const ( + ns = "test-ns" + clusterName = "test-cluster" + cpIP = "10.11.135.36" + ) + + runningCP := makeCPMachine("cp-1", ns, clusterName, true, infrav1beta1.MachineStateDeployed, cpIP) + poweredOffCP := makeCPMachine("cp-1", ns, clusterName, false, infrav1beta1.MachineStateDeployed, cpIP) + + tests := []struct { + name string + maasCluster *infrav1beta1.MaasCluster + machines []client.Object + mockSvc *mockDNSServicer + wantUpdateCalled bool + wantHashSet bool + wantIsDrift bool + }{ + { + name: "CP powered off — DNS not touched, hash not cached", + maasCluster: defaultMaasCluster(), + machines: []client.Object{poweredOffCP}, + mockSvc: &mockDNSServicer{}, + wantUpdateCalled: false, + wantHashSet: false, + }, + { + name: "no CP machines — DNS not touched, hash not cached", + maasCluster: defaultMaasCluster(), + machines: []client.Object{}, + mockSvc: &mockDNSServicer{}, + wantUpdateCalled: false, + wantHashSet: false, + }, + { + name: "CP running, no prior annotation — DNS updated and hash cached", + maasCluster: defaultMaasCluster(), + machines: []client.Object{runningCP}, + mockSvc: &mockDNSServicer{}, + wantUpdateCalled: true, + wantHashSet: true, + }, + { + name: "CP running, hash matches, MAAS in sync — skips update", + maasCluster: func() *infrav1beta1.MaasCluster { + mc := defaultMaasCluster() + mc.Annotations = map[string]string{ + lastAppliedAnn: infrautil.StableHashStringSlice([]string{cpIP}), + } + return mc + }(), + machines: []client.Object{runningCP}, + mockSvc: &mockDNSServicer{isDriftResult: false}, + wantUpdateCalled: false, + wantIsDrift: true, + }, + { + name: "CP running, hash matches, MAAS drifted (empty) — forces re-sync", + maasCluster: func() *infrav1beta1.MaasCluster { + mc := defaultMaasCluster() + mc.Annotations = map[string]string{ + lastAppliedAnn: infrautil.StableHashStringSlice([]string{cpIP}), + } + return mc + }(), + machines: []client.Object{runningCP}, + mockSvc: &mockDNSServicer{isDriftResult: true}, + wantUpdateCalled: true, + wantIsDrift: true, + wantHashSet: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewGomegaWithT(t) + cs := newTestClusterScope(t, tc.maasCluster, tc.machines...) + + err := r.reconcileDNSAttachments(cs, tc.mockSvc) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(tc.mockSvc.updateCalled).To(Equal(tc.wantUpdateCalled), "updateCalled mismatch") + g.Expect(tc.mockSvc.isDriftCalled).To(Equal(tc.wantIsDrift), "isDriftCalled mismatch") + if tc.wantHashSet { + g.Expect(cs.MaasCluster.Annotations).To(HaveKey(lastAppliedAnn)) + g.Expect(cs.MaasCluster.Annotations[lastAppliedAnn]).NotTo(BeEmpty()) + } else if !tc.wantUpdateCalled && !tc.wantIsDrift { + // Hash must not be set to the empty-string sentinel + emptyHash := infrautil.StableHashStringSlice(nil) + if cs.MaasCluster.Annotations != nil { + g.Expect(cs.MaasCluster.Annotations[lastAppliedAnn]).NotTo(Equal(emptyHash), + "should never cache hash of empty IP set") + } + } + }) + } +} diff --git a/pkg/maas/dns/dns.go b/pkg/maas/dns/dns.go index 31186e47d..c165aa404 100644 --- a/pkg/maas/dns/dns.go +++ b/pkg/maas/dns/dns.go @@ -101,11 +101,6 @@ func (s *Service) updateResourceIPs(dnsResource maasclient.DNSResource, IPs []st } } - // Refuse to wipe all A records; callers must guard before reaching here. - if desired.Len() == 0 { - return false, errors.New("refusing to PUT empty IP set to MAAS DNS resource") - } - // Build current set from resource current := sets.NewString() for _, addr := range dnsResource.IPAddresses() { diff --git a/pkg/maas/dns/dns_test.go b/pkg/maas/dns/dns_test.go index b4a80736d..8e3c582c8 100644 --- a/pkg/maas/dns/dns_test.go +++ b/pkg/maas/dns/dns_test.go @@ -261,7 +261,8 @@ func TestDNS(t *testing.T) { maasClient: &fakeClientSet{}, } - // Remove all IPs + // The helper intentionally allows clearing all IPs (deprovisioning use-case). + // The guard against accidental DNS wipe lives in reconcileDNSAttachments, not here. mockDNSResource.EXPECT().IPAddresses().Return([]maasclient.IPAddress{ &fakeIPAddress{ip: net.ParseIP("1.1.1.1")}, }) @@ -272,6 +273,105 @@ func TestDNS(t *testing.T) { updated, err := s.UpdateDNSAttachmentsWithResource(mockDNSResource, []string{}) g.Expect(err).ToNot(HaveOccurred()) - g.Expect(updated).To(BeTrue(), "should update when removing all IPs") + g.Expect(updated).To(BeTrue(), "helper should allow intentional clearing of all IPs") + }) + + t.Run("IsDriftDetected - no drift when IPs match", func(t *testing.T) { + g := NewGomegaWithT(t) + ctrl := gomock.NewController(t) + mockDNSResource := mockclientset.NewMockDNSResource(ctrl) + s := &Service{ + scope: &scope.ClusterScope{Logger: log, Cluster: cluster, MaasCluster: maasCluster}, + maasClient: &fakeClientSet{}, + } + + mockDNSResource.EXPECT().IPAddresses().Return([]maasclient.IPAddress{ + &fakeIPAddress{ip: net.ParseIP("1.1.1.1")}, + &fakeIPAddress{ip: net.ParseIP("2.2.2.2")}, + }) + + g.Expect(s.IsDriftDetected(mockDNSResource, []string{"2.2.2.2", "1.1.1.1"})).To(BeFalse()) + }) + + t.Run("IsDriftDetected - drift when MAAS is empty", func(t *testing.T) { + g := NewGomegaWithT(t) + ctrl := gomock.NewController(t) + mockDNSResource := mockclientset.NewMockDNSResource(ctrl) + s := &Service{ + scope: &scope.ClusterScope{Logger: log, Cluster: cluster, MaasCluster: maasCluster}, + maasClient: &fakeClientSet{}, + } + + mockDNSResource.EXPECT().IPAddresses().Return([]maasclient.IPAddress{}) + + g.Expect(s.IsDriftDetected(mockDNSResource, []string{"1.1.1.1"})).To(BeTrue()) + }) + + t.Run("IsDriftDetected - drift when MAAS has different IPs", func(t *testing.T) { + g := NewGomegaWithT(t) + ctrl := gomock.NewController(t) + mockDNSResource := mockclientset.NewMockDNSResource(ctrl) + s := &Service{ + scope: &scope.ClusterScope{Logger: log, Cluster: cluster, MaasCluster: maasCluster}, + maasClient: &fakeClientSet{}, + } + + mockDNSResource.EXPECT().IPAddresses().Return([]maasclient.IPAddress{ + &fakeIPAddress{ip: net.ParseIP("9.9.9.9")}, + }) + + g.Expect(s.IsDriftDetected(mockDNSResource, []string{"1.1.1.1"})).To(BeTrue()) + }) + + t.Run("IsDriftDetected - drift when MAAS has extra IPs", func(t *testing.T) { + g := NewGomegaWithT(t) + ctrl := gomock.NewController(t) + mockDNSResource := mockclientset.NewMockDNSResource(ctrl) + s := &Service{ + scope: &scope.ClusterScope{Logger: log, Cluster: cluster, MaasCluster: maasCluster}, + maasClient: &fakeClientSet{}, + } + + mockDNSResource.EXPECT().IPAddresses().Return([]maasclient.IPAddress{ + &fakeIPAddress{ip: net.ParseIP("1.1.1.1")}, + &fakeIPAddress{ip: net.ParseIP("2.2.2.2")}, + }) + + // desired has only one; MAAS has two → drift + g.Expect(s.IsDriftDetected(mockDNSResource, []string{"1.1.1.1"})).To(BeTrue()) + }) + + t.Run("IsDriftDetected - duplicate desired IPs treated as one (no spurious drift)", func(t *testing.T) { + g := NewGomegaWithT(t) + ctrl := gomock.NewController(t) + mockDNSResource := mockclientset.NewMockDNSResource(ctrl) + s := &Service{ + scope: &scope.ClusterScope{Logger: log, Cluster: cluster, MaasCluster: maasCluster}, + maasClient: &fakeClientSet{}, + } + + // MAAS has exactly one entry; desired slice has the same IP twice. + // IsDriftDetected must deduplicate and report no drift. + mockDNSResource.EXPECT().IPAddresses().Return([]maasclient.IPAddress{ + &fakeIPAddress{ip: net.ParseIP("1.1.1.1")}, + }) + + g.Expect(s.IsDriftDetected(mockDNSResource, []string{"1.1.1.1", "1.1.1.1"})).To(BeFalse()) + }) + + t.Run("IsDriftDetected - empty strings in desired are ignored", func(t *testing.T) { + g := NewGomegaWithT(t) + ctrl := gomock.NewController(t) + mockDNSResource := mockclientset.NewMockDNSResource(ctrl) + s := &Service{ + scope: &scope.ClusterScope{Logger: log, Cluster: cluster, MaasCluster: maasCluster}, + maasClient: &fakeClientSet{}, + } + + mockDNSResource.EXPECT().IPAddresses().Return([]maasclient.IPAddress{ + &fakeIPAddress{ip: net.ParseIP("1.1.1.1")}, + }) + + g.Expect(s.IsDriftDetected(mockDNSResource, []string{"", "1.1.1.1", ""})).To(BeFalse()) }) } From 3d385d4c4f778fbf0c3f0ebdceb63810847954ec Mon Sep 17 00:00:00 2001 From: Amit Sahastrabuddhe Date: Fri, 8 May 2026 13:47:34 +0530 Subject: [PATCH 3/5] 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 --- controllers/maascluster_controller.go | 36 +++++--- .../maascluster_controller_dns_test.go | 83 +++++++++++++++---- 2 files changed, 89 insertions(+), 30 deletions(-) diff --git a/controllers/maascluster_controller.go b/controllers/maascluster_controller.go index daf36ee60..4d9abd1f0 100644 --- a/controllers/maascluster_controller.go +++ b/controllers/maascluster_controller.go @@ -180,14 +180,20 @@ func (r *MaasClusterReconciler) reconcileDNSAttachments(clusterScope *scope.Clus return errors.Wrapf(err, "unable to find preferred subnets") } - // Build desired IPs first (no MAAS call). Track running CP count separately so we can - // distinguish "machines powered off" from "machines running but IPs filtered by subnets". - var runningCPCount int + // Build desired IPs first (no MAAS call). Track two separate counts: + // existingCPCount — CPs that exist and are NOT being deleted (DeletionTimestamp zero). + // Used to distinguish a transient power-off from a genuine scale-down. + // runningCPCount — CPs that are powered on and running. + // Used to detect preferred-subnet misconfiguration. + var existingCPCount, runningCPCount int var runningIpAddresses []string for _, m := range machines { if !IsControlPlaneMachine(m) { continue } + if m.DeletionTimestamp.IsZero() { + existingCPCount++ + } isRunningHealthy := IsRunning(m) if !m.DeletionTimestamp.IsZero() || !isRunningHealthy { continue @@ -199,17 +205,23 @@ func (r *MaasClusterReconciler) reconcileDNSAttachments(clusterScope *scope.Clus } } - // Never wipe DNS when all CP machines are transiently unavailable (e.g. powered off during - // a flap). An empty desired set would clear the MAAS A records and the hash would be cached - // as SHA-256(""), preventing self-recovery until the controller pod itself is reachable again. if len(runningIpAddresses) == 0 { - if runningCPCount > 0 { - clusterScope.Info("CP machines are running but no IPs match preferred subnets; preserving existing DNS records", - "runningCPs", runningCPCount) - } else { - clusterScope.Info("No running CP machines found; preserving existing DNS records") + if existingCPCount > 0 { + // CP objects exist but are transiently unavailable (powered off / IPs not yet + // assigned / filtered by preferred subnets). Preserve existing DNS records so + // the cluster remains reachable during the flap; DNS self-heals when CPs recover. + if runningCPCount > 0 { + clusterScope.Info("CP machines are running but no IPs match preferred subnets; preserving existing DNS records", + "runningCPs", runningCPCount) + } else { + clusterScope.Info("No running CP machines found; preserving existing DNS records", + "existingCPs", existingCPCount) + } + return nil } - return nil + // No CP objects exist or all are pending deletion (scale-down / rolling replacement + // with no new CPs provisioned yet). Fall through to clear DNS records. + clusterScope.Info("All CP machines absent or pending deletion; clearing DNS records") } // Deduplicate before hashing so the annotation always represents the applied IP *set*. diff --git a/controllers/maascluster_controller_dns_test.go b/controllers/maascluster_controller_dns_test.go index f22d63de8..244315dbf 100644 --- a/controllers/maascluster_controller_dns_test.go +++ b/controllers/maascluster_controller_dns_test.go @@ -2,8 +2,10 @@ package controllers import ( "testing" + "time" . "github.com/onsi/gomega" + "github.com/pkg/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/klog/v2/klogr" @@ -237,36 +239,69 @@ func TestReconcileDNSAttachments(t *testing.T) { runningCP := makeCPMachine("cp-1", ns, clusterName, true, infrav1beta1.MachineStateDeployed, cpIP) poweredOffCP := makeCPMachine("cp-1", ns, clusterName, false, infrav1beta1.MachineStateDeployed, cpIP) + deletedCP := func() *infrav1beta1.MaasMachine { + m := makeCPMachine("cp-deleted", ns, clusterName, true, infrav1beta1.MachineStateDeployed, cpIP) + m.Finalizers = []string{"test-finalizer"} // required for DeletionTimestamp to be set + ts := metav1.NewTime(time.Now()) + m.DeletionTimestamp = &ts + return m + }() + + runningCPNoIP := makeCPMachine("cp-no-ip", ns, clusterName, true, infrav1beta1.MachineStateDeployed, "") // powered on but no ExternalIP + tests := []struct { - name string - maasCluster *infrav1beta1.MaasCluster - machines []client.Object - mockSvc *mockDNSServicer + name string + maasCluster *infrav1beta1.MaasCluster + machines []client.Object + mockSvc *mockDNSServicer wantUpdateCalled bool - wantHashSet bool - wantIsDrift bool + wantHashSet bool + wantIsDrift bool + wantErr bool }{ { - name: "CP powered off — DNS not touched, hash not cached", - maasCluster: defaultMaasCluster(), - machines: []client.Object{poweredOffCP}, - mockSvc: &mockDNSServicer{}, + name: "CP powered off — existingCPCount>0, DNS preserved", + maasCluster: defaultMaasCluster(), + machines: []client.Object{poweredOffCP}, + mockSvc: &mockDNSServicer{}, wantUpdateCalled: false, wantHashSet: false, }, { - name: "no CP machines — DNS not touched, hash not cached", - maasCluster: defaultMaasCluster(), - machines: []client.Object{}, - mockSvc: &mockDNSServicer{}, + // Fix for #2: with no CP objects, DNS must be cleared (e.g. rolling replacement gap). + // Before this fix, the guard returned nil unconditionally when IPs were empty. + name: "no CP machines — existingCPCount=0, DNS cleared", + maasCluster: defaultMaasCluster(), + machines: []client.Object{}, + mockSvc: &mockDNSServicer{}, + wantUpdateCalled: true, + wantHashSet: true, + }, + { + // Fix for #2: CP has DeletionTimestamp → excluded from existingCPCount → + // DNS should be cleared, not preserved. + name: "CP with DeletionTimestamp — existingCPCount=0, DNS cleared", + maasCluster: defaultMaasCluster(), + machines: []client.Object{deletedCP}, + mockSvc: &mockDNSServicer{}, + wantUpdateCalled: true, + wantHashSet: true, + }, + { + // Fix for #5: CP is running (existingCPCount=1, runningCPCount=1) but has no + // ExternalIP (same code path as preferred-subnet mismatch) → DNS preserved. + name: "CP running but no ExternalIP — existingCPCount>0, DNS preserved", + maasCluster: defaultMaasCluster(), + machines: []client.Object{runningCPNoIP}, + mockSvc: &mockDNSServicer{}, wantUpdateCalled: false, wantHashSet: false, }, { - name: "CP running, no prior annotation — DNS updated and hash cached", - maasCluster: defaultMaasCluster(), - machines: []client.Object{runningCP}, - mockSvc: &mockDNSServicer{}, + name: "CP running, no prior annotation — DNS updated and hash cached", + maasCluster: defaultMaasCluster(), + machines: []client.Object{runningCP}, + mockSvc: &mockDNSServicer{}, wantUpdateCalled: true, wantHashSet: true, }, @@ -299,6 +334,14 @@ func TestReconcileDNSAttachments(t *testing.T) { wantIsDrift: true, wantHashSet: true, }, + { + // Fix for #6: GetDNSResource error must propagate to the caller. + name: "GetDNSResource error is propagated", + maasCluster: defaultMaasCluster(), + machines: []client.Object{runningCP}, + mockSvc: &mockDNSServicer{getDNSResourceErr: errors.New("MAAS unreachable")}, + wantErr: true, + }, } for _, tc := range tests { @@ -308,6 +351,10 @@ func TestReconcileDNSAttachments(t *testing.T) { err := r.reconcileDNSAttachments(cs, tc.mockSvc) + if tc.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } g.Expect(err).ToNot(HaveOccurred()) g.Expect(tc.mockSvc.updateCalled).To(Equal(tc.wantUpdateCalled), "updateCalled mismatch") g.Expect(tc.mockSvc.isDriftCalled).To(Equal(tc.wantIsDrift), "isDriftCalled mismatch") From 7f3432acc484e76e3fb68b40dcf4fe642d521690 Mon Sep 17 00:00:00 2001 From: Amit Sahastrabuddhe Date: Fri, 8 May 2026 14:14:26 +0530 Subject: [PATCH 4/5] Fix go vul check --- go.mod | 12 ++++++------ go.sum | 12 ++++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index d0b8b6bf3..31e466ce1 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/spectrocloud/cluster-api-provider-maas -go 1.25.8 +go 1.25.10 require ( github.com/go-logr/logr v1.4.2 @@ -58,12 +58,12 @@ require ( github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect - golang.org/x/net v0.38.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.28.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.8.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.5 // indirect diff --git a/go.sum b/go.sum index 7ced6eaba..c2af465bf 100644 --- a/go.sum +++ b/go.sum @@ -219,6 +219,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -233,6 +234,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -242,6 +245,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/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= @@ -256,13 +261,19 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= 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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -273,6 +284,7 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 85876cf0bd76b850ff9624397627be33a3e1c8aa Mon Sep 17 00:00:00 2001 From: Amit Sahastrabuddhe Date: Fri, 15 May 2026 19:15:17 +0530 Subject: [PATCH 5/5] Fix go.mod --- go.mod | 1 - go.sum | 16 ++-------------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 2d34a8393..7f7d232b1 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,6 @@ module github.com/spectrocloud/cluster-api-provider-maas go 1.25.10 - require ( github.com/go-logr/logr v1.4.2 github.com/golang/mock v1.6.0 diff --git a/go.sum b/go.sum index 7fe855d2a..44fa909e0 100644 --- a/go.sum +++ b/go.sum @@ -217,9 +217,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -232,8 +231,6 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= @@ -243,8 +240,6 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ 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.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -259,19 +254,13 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= 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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= @@ -282,9 +271,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= -golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=