From 8382e58eaed1e02e8231b7918f45d2e322988565 Mon Sep 17 00:00:00 2001 From: Pedro Coutinho Date: Tue, 7 Jul 2026 15:55:51 -0700 Subject: [PATCH] [CORE-12994] feat: operator RBAC for HostQoSPolicy Grant the calico-apiserver backing-store CRUD on hostqospolicies (+/status), the Enterprise calico-kube-controllers the verbs its status aggregator needs, and calico-node plus the non-cluster-host SA patch on hostqospolicies/status for Felix's Server-Side Apply writes. Enterprise-only; OSS RBAC is unchanged. --- pkg/render/apiserver.go | 2 + pkg/render/apiserver_test.go | 22 +++++++++++ .../kubecontrollers/kube-controllers.go | 8 ++++ .../kubecontrollers/kube-controllers_test.go | 13 +++++-- pkg/render/node.go | 10 ++++- pkg/render/node_test.go | 38 +++++++++++++++++++ pkg/render/nonclusterhost/nonclusterhost.go | 9 +++++ .../nonclusterhost/nonclusterhost_test.go | 6 +++ pkg/render/typha.go | 2 +- pkg/render/typha_test.go | 34 +++++++++++++++++ 10 files changed, 139 insertions(+), 5 deletions(-) diff --git a/pkg/render/apiserver.go b/pkg/render/apiserver.go index 1d7e6ff4b6..2555ab1b32 100644 --- a/pkg/render/apiserver.go +++ b/pkg/render/apiserver.go @@ -1452,6 +1452,8 @@ func (c *apiServerComponent) tigeraAPIServerClusterRole() *rbacv1.ClusterRole { "globalreporttypes", "globalthreatfeeds", "globalthreatfeeds/status", + "hostqospolicies", + "hostqospolicies/status", "licensekeys", "managedclusters", "managedclusters/status", diff --git a/pkg/render/apiserver_test.go b/pkg/render/apiserver_test.go index 7f0ea07006..aa52e0130c 100644 --- a/pkg/render/apiserver_test.go +++ b/pkg/render/apiserver_test.go @@ -407,6 +407,28 @@ var _ = Describe("API server rendering tests (Calico Enterprise)", func() { Expect(clusterRole.Rules).To(ConsistOf(append(networkAdminPolicyRules, rbacManagementNetworkAdminRules...))) }) + It("should grant the calico-apiserver access to hostqospolicies backing storage", func() { + component, err := render.APIServer(cfg) + Expect(err).NotTo(HaveOccurred()) + resources, _ := component.Objects() + + clusterRole := rtest.GetResource(resources, "calico-apiserver", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) + + var backingRule *rbacv1.PolicyRule + for i := range clusterRole.Rules { + r := &clusterRole.Rules[i] + if len(r.APIGroups) == 2 && r.APIGroups[0] == "projectcalico.org" && r.APIGroups[1] == "crd.projectcalico.org" { + for _, res := range r.Resources { + if res == "alertexceptions" { + backingRule = r + } + } + } + } + Expect(backingRule).NotTo(BeNil(), "expected the Enterprise backing-storage rule") + Expect(backingRule.Resources).To(ContainElements("hostqospolicies", "hostqospolicies/status")) + }) + It("should render resources without an aggregation server", func() { cfg.RequiresAggregationServer = false diff --git a/pkg/render/kubecontrollers/kube-controllers.go b/pkg/render/kubecontrollers/kube-controllers.go index f8f2dae650..7a8961ce5a 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -572,6 +572,14 @@ func kubeControllersRoleEnterpriseCommonRules(cfg *KubeControllersConfiguration) Resources: []string{"packetcaptures/status"}, Verbs: []string{"update"}, }, + { + // The HostQoSPolicy status aggregator watches status written per-node by + // each Felix and rolls it up into the Ready condition via the /status + // subresource. Mirrors the ippools/status pattern. + APIGroups: []string{"projectcalico.org", "crd.projectcalico.org"}, + Resources: []string{"hostqospolicies", "hostqospolicies/status"}, + Verbs: []string{"get", "list", "watch", "update"}, + }, } if cfg.WAFGatewayExtensionEnabled { diff --git a/pkg/render/kubecontrollers/kube-controllers_test.go b/pkg/render/kubecontrollers/kube-controllers_test.go index 4ad441a65b..ee47921118 100644 --- a/pkg/render/kubecontrollers/kube-controllers_test.go +++ b/pkg/render/kubecontrollers/kube-controllers_test.go @@ -315,7 +315,14 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(len(dp.Spec.Template.Spec.Volumes)).To(Equal(1)) clusterRole := rtest.GetResource(resources, kubecontrollers.KubeControllerRole, "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(HaveLen(38), "cluster role should have 38 rules") + Expect(clusterRole.Rules).To(HaveLen(39), "cluster role should have 39 rules") + + // HostQoSPolicy status aggregator RBAC (Enterprise only). + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"projectcalico.org", "crd.projectcalico.org"}, + Resources: []string{"hostqospolicies", "hostqospolicies/status"}, + Verbs: []string{"get", "list", "watch", "update"}, + })) // Application-layer reconciler RBAC: WAF CRDs (resources, /status, /finalizers). Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ @@ -533,7 +540,7 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(dp.Spec.Template.Spec.Volumes[0].ConfigMap.Name).To(Equal("tigera-ca-bundle")) clusterRole := rtest.GetResource(resources, kubecontrollers.EsKubeControllerRole, "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(HaveLen(36), "cluster role should have 36 rules") + Expect(clusterRole.Rules).To(HaveLen(37), "cluster role should have 37 rules") Expect(clusterRole.Rules).To(ContainElement( rbacv1.PolicyRule{ APIGroups: []string{""}, @@ -794,7 +801,7 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(dp.Spec.Template.Spec.Containers[0].Image).To(Equal("test-reg/tigera/calico:" + components.ComponentTigeraCalico.Version)) clusterRole := rtest.GetResource(resources, kubecontrollers.EsKubeControllerRole, "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(HaveLen(36), "cluster role should have 36 rules") + Expect(clusterRole.Rules).To(HaveLen(37), "cluster role should have 37 rules") Expect(clusterRole.Rules).To(ContainElement( rbacv1.PolicyRule{ APIGroups: []string{""}, diff --git a/pkg/render/node.go b/pkg/render/node.go index 06282683f4..81a5149b12 100644 --- a/pkg/render/node.go +++ b/pkg/render/node.go @@ -476,7 +476,6 @@ func (c *nodeComponent) nodeRole() *rbacv1.ClusterRole { "globalnetworkpolicies", "globalnetworksets", "hostendpoints", - "hostqospolicies", "ipamblocks", "ippools", "ipreservations", @@ -576,6 +575,7 @@ func (c *nodeComponent) nodeRole() *rbacv1.ClusterRole { "bfdconfigurations", "egressgatewaypolicies", "externalnetworks", + "hostqospolicies", "licensekeys", "networks", "packetcaptures", @@ -592,6 +592,14 @@ func (c *nodeComponent) nodeRole() *rbacv1.ClusterRole { }, Verbs: []string{"update"}, }, + { + // Felix writes its own per-node entry in HostQoSPolicy status.nodes[] + // via Server-Side Apply (FieldManager: felix-). SSA is an + // HTTP PATCH, so patch is required rather than update. + APIGroups: []string{"projectcalico.org", "crd.projectcalico.org"}, + Resources: []string{"hostqospolicies/status"}, + Verbs: []string{"patch"}, + }, } role.Rules = append(role.Rules, extraRules...) } diff --git a/pkg/render/node_test.go b/pkg/render/node_test.go index eb2c3efaf8..fe005474c9 100644 --- a/pkg/render/node_test.go +++ b/pkg/render/node_test.go @@ -741,6 +741,44 @@ var _ = Describe("Node rendering tests", func() { verifyProbesAndLifecycle(ds, false, true) }) + It("should grant calico-node HostQoSPolicy RBAC only for Enterprise", func() { + // Enterprise: read access plus patch on the status subresource, which + // Felix writes via Server-Side Apply. + defaultInstance.Variant = operatorv1.CalicoEnterprise + component := render.Node(&cfg) + Expect(component.ResolveImages(nil)).To(BeNil()) + resources, _ := component.Objects() + role := rtest.GetResource(resources, "calico-node", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) + + Expect(role.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"projectcalico.org", "crd.projectcalico.org"}, + Resources: []string{"hostqospolicies/status"}, + Verbs: []string{"patch"}, + })) + var readsHostQoS bool + for _, rule := range role.Rules { + for _, res := range rule.Resources { + if res == "hostqospolicies" { + readsHostQoS = true + } + } + } + Expect(readsHostQoS).To(BeTrue(), "Enterprise calico-node should read hostqospolicies") + + // Calico (OSS): the CRD does not exist, so neither grant is rendered. + defaultInstance.Variant = operatorv1.Calico + component = render.Node(&cfg) + Expect(component.ResolveImages(nil)).To(BeNil()) + resources, _ = component.Objects() + role = rtest.GetResource(resources, "calico-node", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) + for _, rule := range role.Rules { + for _, res := range rule.Resources { + Expect(res).NotTo(Equal("hostqospolicies")) + Expect(res).NotTo(Equal("hostqospolicies/status")) + } + } + }) + It("should render felix service metric with FelixPrometheusMetricPort when FelixPrometheusMetricsEnabled is true", func() { defaultInstance.Variant = operatorv1.CalicoEnterprise cfg.NodeReporterMetricsPort = 9081 diff --git a/pkg/render/nonclusterhost/nonclusterhost.go b/pkg/render/nonclusterhost/nonclusterhost.go index 30d64f95fd..9fd96f96c4 100644 --- a/pkg/render/nonclusterhost/nonclusterhost.go +++ b/pkg/render/nonclusterhost/nonclusterhost.go @@ -160,6 +160,7 @@ func (c *nonClusterHostComponent) clusterRole() *rbacv1.ClusterRole { "globalnetworkpolicies", "globalnetworksets", "hostendpoints", + "hostqospolicies", "ipamblocks", "ippools", "licensekeys", @@ -175,6 +176,14 @@ func (c *nonClusterHostComponent) clusterRole() *rbacv1.ClusterRole { }, Verbs: []string{"get", "list", "watch"}, }, + { + // Felix on a non-cluster host writes its own per-node entry in + // HostQoSPolicy status.nodes[] via Server-Side Apply, which is an + // HTTP PATCH. + APIGroups: []string{"projectcalico.org", "crd.projectcalico.org"}, + Resources: []string{"hostqospolicies/status"}, + Verbs: []string{"patch"}, + }, } // Calico fluent-bit rules diff --git a/pkg/render/nonclusterhost/nonclusterhost_test.go b/pkg/render/nonclusterhost/nonclusterhost_test.go index 91f31a2876..20d37338bb 100644 --- a/pkg/render/nonclusterhost/nonclusterhost_test.go +++ b/pkg/render/nonclusterhost/nonclusterhost_test.go @@ -122,6 +122,7 @@ var _ = Describe("NonClusterHost rendering tests", func() { "globalnetworkpolicies", "globalnetworksets", "hostendpoints", + "hostqospolicies", "ipamblocks", "ippools", "licensekeys", @@ -137,6 +138,11 @@ var _ = Describe("NonClusterHost rendering tests", func() { }, Verbs: []string{"get", "list", "watch"}, }, + rbacv1.PolicyRule{ + APIGroups: []string{"projectcalico.org", "crd.projectcalico.org"}, + Resources: []string{"hostqospolicies/status"}, + Verbs: []string{"patch"}, + }, rbacv1.PolicyRule{ APIGroups: []string{""}, Resources: []string{"serviceaccounts/token"}, diff --git a/pkg/render/typha.go b/pkg/render/typha.go index 264851785f..95ce1e9ba7 100644 --- a/pkg/render/typha.go +++ b/pkg/render/typha.go @@ -282,7 +282,6 @@ func (c *typhaComponent) typhaRole() *rbacv1.ClusterRole { "stagednetworkpolicies", "globalnetworksets", "hostendpoints", - "hostqospolicies", "ipamblocks", "ippools", "ipreservations", @@ -366,6 +365,7 @@ func (c *typhaComponent) typhaRole() *rbacv1.ClusterRole { "deeppacketinspections", "egressgatewaypolicies", "externalnetworks", + "hostqospolicies", "licensekeys", "networks", "packetcaptures", diff --git a/pkg/render/typha_test.go b/pkg/render/typha_test.go index 6751058601..e7e0af4ef5 100644 --- a/pkg/render/typha_test.go +++ b/pkg/render/typha_test.go @@ -135,6 +135,40 @@ var _ = Describe("Typha rendering tests", func() { })) }) + It("should grant calico-typha read on hostqospolicies only for Enterprise", func() { + hostQoSInRules := func(role *rbacv1.ClusterRole) bool { + for _, rule := range role.Rules { + for _, res := range rule.Resources { + if res == "hostqospolicies" { + return true + } + } + } + return false + } + + cfg.Installation.Variant = operatorv1.CalicoEnterprise + component := render.Typha(&cfg) + Expect(component.ResolveImages(nil)).To(BeNil()) + resources, _ := component.Objects() + role := rtest.GetResource(resources, "calico-typha", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) + Expect(hostQoSInRules(role)).To(BeTrue(), "Enterprise calico-typha should read hostqospolicies") + + // Typha only relays the syncer, so it never writes status. + Expect(role.Rules).NotTo(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"projectcalico.org", "crd.projectcalico.org"}, + Resources: []string{"hostqospolicies/status"}, + Verbs: []string{"patch"}, + })) + + cfg.Installation.Variant = operatorv1.Calico + component = render.Typha(&cfg) + Expect(component.ResolveImages(nil)).To(BeNil()) + resources, _ = component.Objects() + role = rtest.GetResource(resources, "calico-typha", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) + Expect(hostQoSInRules(role)).To(BeFalse(), "Calico OSS calico-typha should not reference hostqospolicies") + }) + It("should render all resources for a default configuration", func() { expectedResources := []struct { name string